From 91b0cdc8712e3f23055d0f8ece836da53eb63c05 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Thu, 8 Jun 2023 18:36:41 +0200 Subject: [PATCH 01/51] Plugins: Account for nil user when constructing plugin context (#69811) cater for nil user --- pkg/api/plugin_resource.go | 2 +- pkg/api/plugins.go | 2 +- pkg/services/live/liveplugin/plugin.go | 2 +- .../plugincontext/plugincontext.go | 36 ++++++++++++------- 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/pkg/api/plugin_resource.go b/pkg/api/plugin_resource.go index 8ca1a20dbf3..6f94a627ad2 100644 --- a/pkg/api/plugin_resource.go +++ b/pkg/api/plugin_resource.go @@ -26,7 +26,7 @@ func (hs *HTTPServer) CallResource(c *contextmodel.ReqContext) { } func (hs *HTTPServer) callPluginResource(c *contextmodel.ReqContext, pluginID string) { - pCtx, err := hs.pluginContextProvider.Get(c.Req.Context(), pluginID, c.SignedInUser) + pCtx, err := hs.pluginContextProvider.Get(c.Req.Context(), pluginID, c.SignedInUser, c.OrgID) if err != nil { if errors.Is(err, plugincontext.ErrPluginNotFound) { c.JsonApiErr(404, "Plugin not found", nil) diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 19bfd231472..55c7395620a 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -393,7 +393,7 @@ func (hs *HTTPServer) redirectCDNPluginAsset(c *contextmodel.ReqContext, plugin // /api/plugins/:pluginId/health func (hs *HTTPServer) CheckHealth(c *contextmodel.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] - pCtx, err := hs.pluginContextProvider.Get(c.Req.Context(), pluginID, c.SignedInUser) + pCtx, err := hs.pluginContextProvider.Get(c.Req.Context(), pluginID, c.SignedInUser, c.OrgID) if err != nil { if errors.Is(err, plugincontext.ErrPluginNotFound) { return response.Error(404, "Plugin not found", nil) diff --git a/pkg/services/live/liveplugin/plugin.go b/pkg/services/live/liveplugin/plugin.go index cda9e012b30..8a9a9a35c99 100644 --- a/pkg/services/live/liveplugin/plugin.go +++ b/pkg/services/live/liveplugin/plugin.go @@ -74,7 +74,7 @@ func NewContextGetter(pluginContextProvider *plugincontext.Provider, dataSourceC func (g *ContextGetter) GetPluginContext(ctx context.Context, user *user.SignedInUser, pluginID string, datasourceUID string, skipCache bool) (backend.PluginContext, error) { if datasourceUID == "" { - return g.pluginContextProvider.Get(ctx, pluginID, user) + return g.pluginContextProvider.Get(ctx, pluginID, user, user.OrgID) } ds, err := g.dataSourceCache.GetDatasourceByUID(ctx, datasourceUID, user, skipCache) diff --git a/pkg/services/pluginsintegration/plugincontext/plugincontext.go b/pkg/services/pluginsintegration/plugincontext/plugincontext.go index 1dea0e6af6b..34b9be7056c 100644 --- a/pkg/services/pluginsintegration/plugincontext/plugincontext.go +++ b/pkg/services/pluginsintegration/plugincontext/plugincontext.go @@ -39,20 +39,23 @@ type Provider struct { // Get allows getting plugin context by its ID. If datasourceUID is not empty string // then PluginContext.DataSourceInstanceSettings will be resolved and appended to // returned context. -func (p *Provider) Get(ctx context.Context, pluginID string, user *user.SignedInUser) (backend.PluginContext, error) { +// Note: *user.SignedInUser can be nil. +func (p *Provider) Get(ctx context.Context, pluginID string, user *user.SignedInUser, orgID int64) (backend.PluginContext, error) { plugin, exists := p.pluginStore.Plugin(ctx, pluginID) if !exists { return backend.PluginContext{}, ErrPluginNotFound } pCtx := backend.PluginContext{ - OrgID: user.OrgID, PluginID: pluginID, - User: adapters.BackendUserFromSignedInUser(user), + } + if user != nil { + pCtx.OrgID = user.OrgID + pCtx.User = adapters.BackendUserFromSignedInUser(user) } if plugin.IsApp() { - appSettings, err := p.appInstanceSettings(ctx, pluginID, user) + appSettings, err := p.appInstanceSettings(ctx, pluginID, orgID) if err != nil { return backend.PluginContext{}, err } @@ -64,10 +67,19 @@ func (p *Provider) Get(ctx context.Context, pluginID string, user *user.SignedIn // GetWithDataSource allows getting plugin context by its ID and PluginContext.DataSourceInstanceSettings will be // resolved and appended to the returned context. +// Note: *user.SignedInUser can be nil. func (p *Provider) GetWithDataSource(ctx context.Context, pluginID string, user *user.SignedInUser, ds *datasources.DataSource) (backend.PluginContext, error) { - pCtx, err := p.Get(ctx, pluginID, user) - if err != nil { - return backend.PluginContext{}, err + _, exists := p.pluginStore.Plugin(ctx, pluginID) + if !exists { + return backend.PluginContext{}, ErrPluginNotFound + } + + pCtx := backend.PluginContext{ + PluginID: pluginID, + } + if user != nil { + pCtx.OrgID = user.OrgID + pCtx.User = adapters.BackendUserFromSignedInUser(user) } datasourceSettings, err := adapters.ModelToInstanceSettings(ds, p.decryptSecureJsonDataFn(ctx)) @@ -82,12 +94,12 @@ func (p *Provider) GetWithDataSource(ctx context.Context, pluginID string, user const pluginSettingsCacheTTL = 5 * time.Second const pluginSettingsCachePrefix = "plugin-setting-" -func (p *Provider) appInstanceSettings(ctx context.Context, pluginID string, user *user.SignedInUser) (*backend.AppInstanceSettings, error) { +func (p *Provider) appInstanceSettings(ctx context.Context, pluginID string, orgID int64) (*backend.AppInstanceSettings, error) { jsonData := json.RawMessage{} decryptedSecureJSONData := map[string]string{} var updated time.Time - ps, err := p.getCachedPluginSettings(ctx, pluginID, user) + ps, err := p.getCachedPluginSettings(ctx, pluginID, orgID) if err != nil { // pluginsettings.ErrPluginSettingNotFound is expected if there's no row found for plugin setting in database (if non-app plugin). // Otherwise, something is wrong with cache or database, and we return the error to the client. @@ -114,19 +126,19 @@ func (p *Provider) InvalidateSettingsCache(_ context.Context, pluginID string) { p.cacheService.Delete(getCacheKey(pluginID)) } -func (p *Provider) getCachedPluginSettings(ctx context.Context, pluginID string, user *user.SignedInUser) (*pluginsettings.DTO, error) { +func (p *Provider) getCachedPluginSettings(ctx context.Context, pluginID string, orgID int64) (*pluginsettings.DTO, error) { cacheKey := getCacheKey(pluginID) if cached, found := p.cacheService.Get(cacheKey); found { ps := cached.(*pluginsettings.DTO) - if ps.OrgID == user.OrgID { + if ps.OrgID == orgID { return ps, nil } } ps, err := p.pluginSettingsService.GetPluginSettingByPluginID(ctx, &pluginsettings.GetByPluginIDArgs{ PluginID: pluginID, - OrgID: user.OrgID, + OrgID: orgID, }) if err != nil { return nil, err From cae3b4c6e646979abcbdb293e869f45355022f52 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 8 Jun 2023 18:44:26 +0200 Subject: [PATCH 02/51] grafana/schema: Make composable types part of the package (#69678) * grafana/schema: Make composable types part of the package * Add glob as dev dependency * Review --- packages/grafana-schema/package.json | 1 + packages/grafana-schema/rollup.config.ts | 19 ++- packages/grafana-schema/tsconfig.json | 2 +- yarn.lock | 190 +++++++++++++++++++---- 4 files changed, 183 insertions(+), 29 deletions(-) diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index e680dee9190..6d5ac44981c 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -41,6 +41,7 @@ "@rollup/plugin-json": "5.0.1", "@rollup/plugin-node-resolve": "15.1.0", "esbuild": "0.17.19", + "glob": "^10.2.7", "rimraf": "4.4.0", "rollup": "2.79.1", "rollup-plugin-dts": "^5.0.0", diff --git a/packages/grafana-schema/rollup.config.ts b/packages/grafana-schema/rollup.config.ts index 997b0ae4740..d75af54dbfc 100644 --- a/packages/grafana-schema/rollup.config.ts +++ b/packages/grafana-schema/rollup.config.ts @@ -1,4 +1,6 @@ import resolve from '@rollup/plugin-node-resolve'; +import glob from 'glob'; +import { fileURLToPath } from 'node:url'; import path from 'path'; import dts from 'rollup-plugin-dts'; import esbuild from 'rollup-plugin-esbuild'; @@ -27,11 +29,26 @@ export default [ ], }, { - input: './compiled/index.d.ts', + input: './dist/esm/index.d.ts', plugins: [dts()], output: { file: pkg.publishConfig.types, format: 'es', }, }, + { + input: Object.fromEntries( + glob + .sync('src/raw/composable/**/*.ts') + .map((file) => [ + path.relative('src', file.slice(0, file.length - path.extname(file).length)), + fileURLToPath(new URL(file, import.meta.url)), + ]) + ), + plugins: [resolve(), esbuild()], + output: { + format: 'esm', + dir: path.dirname(pkg.publishConfig.module), + }, + }, ]; diff --git a/packages/grafana-schema/tsconfig.json b/packages/grafana-schema/tsconfig.json index d6b875f8927..99cbc657b72 100644 --- a/packages/grafana-schema/tsconfig.json +++ b/packages/grafana-schema/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "declarationDir": "./compiled", + "declarationDir": "./dist/esm", "emitDeclarationOnly": true, "isolatedModules": true, "rootDirs": ["."] diff --git a/yarn.lock b/yarn.lock index 3b2910090a4..27f264c8080 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3581,6 +3581,7 @@ __metadata: "@rollup/plugin-json": 5.0.1 "@rollup/plugin-node-resolve": 15.1.0 esbuild: 0.17.19 + glob: ^10.2.7 rimraf: 4.4.0 rollup: 2.79.1 rollup-plugin-dts: ^5.0.0 @@ -3870,6 +3871,20 @@ __metadata: languageName: node linkType: hard +"@isaacs/cliui@npm:^8.0.2": + version: 8.0.2 + resolution: "@isaacs/cliui@npm:8.0.2" + dependencies: + string-width: ^5.1.2 + string-width-cjs: "npm:string-width@^4.2.0" + strip-ansi: ^7.0.1 + strip-ansi-cjs: "npm:strip-ansi@^6.0.1" + wrap-ansi: ^8.1.0 + wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0" + checksum: 4a473b9b32a7d4d3cfb7a614226e555091ff0c5a29a1734c28c72a182c2f6699b26fc6b5c2131dfd841e86b185aea714c72201d7c98c2fba5f17709333a67aeb + languageName: node + linkType: hard + "@isaacs/string-locale-compare@npm:^1.1.0": version: 1.1.0 resolution: "@isaacs/string-locale-compare@npm:1.1.0" @@ -6025,6 +6040,13 @@ __metadata: languageName: node linkType: hard +"@pkgjs/parseargs@npm:^0.11.0": + version: 0.11.0 + resolution: "@pkgjs/parseargs@npm:0.11.0" + checksum: 6ad6a00fc4f2f2cfc6bff76fb1d88b8ee20bc0601e18ebb01b6d4be583733a860239a521a7fbca73b612e66705078809483549d2b18f370eb346c5155c8e4a0f + languageName: node + linkType: hard + "@pmmmwh/react-refresh-webpack-plugin@npm:0.5.10, @pmmmwh/react-refresh-webpack-plugin@npm:^0.5.5": version: 0.5.10 resolution: "@pmmmwh/react-refresh-webpack-plugin@npm:0.5.10" @@ -11407,6 +11429,13 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^6.1.0": + version: 6.2.1 + resolution: "ansi-styles@npm:6.2.1" + checksum: ef940f2f0ced1a6347398da88a91da7930c33ecac3c77b72c5905f8b8fe402c52e6fde304ff5347f616e27a742da3f1dc76de98f6866c69251ad0b07a66776d9 + languageName: node + linkType: hard + "ansicolor@npm:1.1.100": version: 1.1.100 resolution: "ansicolor@npm:1.1.100" @@ -15464,6 +15493,13 @@ __metadata: languageName: node linkType: hard +"eastasianwidth@npm:^0.2.0": + version: 0.2.0 + resolution: "eastasianwidth@npm:0.2.0" + checksum: 7d00d7cd8e49b9afa762a813faac332dee781932d6f2c848dc348939c4253f1d4564341b7af1d041853bc3f32c2ef141b58e0a4d9862c17a7f08f68df1e0f1ed + languageName: node + linkType: hard + "ecc-jsbn@npm:~0.1.1": version: 0.1.2 resolution: "ecc-jsbn@npm:0.1.2" @@ -17296,6 +17332,16 @@ __metadata: languageName: node linkType: hard +"foreground-child@npm:^3.1.0": + version: 3.1.1 + resolution: "foreground-child@npm:3.1.1" + dependencies: + cross-spawn: ^7.0.0 + signal-exit: ^4.0.1 + checksum: 139d270bc82dc9e6f8bc045fe2aae4001dc2472157044fdfad376d0a3457f77857fa883c1c8b21b491c6caade9a926a4bed3d3d2e8d3c9202b151a4cbbd0bcd5 + languageName: node + linkType: hard + "forever-agent@npm:~0.6.1": version: 0.6.1 resolution: "forever-agent@npm:0.6.1" @@ -18004,6 +18050,21 @@ __metadata: languageName: node linkType: hard +"glob@npm:^10.2.7": + version: 10.2.7 + resolution: "glob@npm:10.2.7" + dependencies: + foreground-child: ^3.1.0 + jackspeak: ^2.0.3 + minimatch: ^9.0.1 + minipass: ^5.0.0 || ^6.0.2 + path-scurry: ^1.7.0 + bin: + glob: dist/cjs/src/bin.js + checksum: 555205a74607d6f8d9874ba888924b305b5ea1abfaa2e9ccb11ac713d040aac7edbf7d8702a2f4a1cd81b2d7666412170ce7ef061d33cddde189dae8c1a1a054 + languageName: node + linkType: hard + "glob@npm:^7.0.0": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -20483,6 +20544,19 @@ __metadata: languageName: node linkType: hard +"jackspeak@npm:^2.0.3": + version: 2.2.1 + resolution: "jackspeak@npm:2.2.1" + dependencies: + "@isaacs/cliui": ^8.0.2 + "@pkgjs/parseargs": ^0.11.0 + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: e29291c0d0f280a063fa18fbd1e891ab8c2d7519fd34052c0ebde38538a15c603140d60c2c7f432375ff7ee4c5f1c10daa8b2ae19a97c3d4affe308c8360c1df + languageName: node + linkType: hard + "jake@npm:^10.8.5": version: 10.8.5 resolution: "jake@npm:10.8.5" @@ -22169,6 +22243,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^9.1.1": + version: 9.1.2 + resolution: "lru-cache@npm:9.1.2" + checksum: d3415634be3908909081fc4c56371a8d562d9081eba70543d86871b978702fffd0e9e362b83921b27a29ae2b37b90f55675aad770a54ac83bb3e4de5049d4b15 + languageName: node + linkType: hard + "lru-memoize@npm:^1.1.0": version: 1.1.0 resolution: "lru-memoize@npm:1.1.0" @@ -22669,6 +22750,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^9.0.1": + version: 9.0.1 + resolution: "minimatch@npm:9.0.1" + dependencies: + brace-expansion: ^2.0.1 + checksum: 97f5f5284bb57dc65b9415dec7f17a0f6531a33572193991c60ff18450dcfad5c2dad24ffeaf60b5261dccd63aae58cc3306e2209d57e7f88c51295a532d8ec3 + languageName: node + linkType: hard + "minimist-options@npm:4.1.0": version: 4.1.0 resolution: "minimist-options@npm:4.1.0" @@ -22802,6 +22892,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^5.0.0 || ^6.0.2": + version: 6.0.2 + resolution: "minipass@npm:6.0.2" + checksum: d140b91f4ab2e5ce5a9b6c468c0e82223504acc89114c1a120d4495188b81fedf8cade72a9f4793642b4e66672f990f1e0d902dd858485216a07cd3c8a62fac9 + languageName: node + linkType: hard + "minizlib@npm:^2.0.0, minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": version: 2.1.2 resolution: "minizlib@npm:2.1.2" @@ -24457,6 +24554,16 @@ __metadata: languageName: node linkType: hard +"path-scurry@npm:^1.7.0": + version: 1.9.2 + resolution: "path-scurry@npm:1.9.2" + dependencies: + lru-cache: ^9.1.1 + minipass: ^5.0.0 || ^6.0.2 + checksum: 92888dfb68e285043c6d3291c8e971d5d2bc2f5082f4d7b5392896f34be47024c9d0a8b688dd7ae6d125acc424699195474927cb4f00049a9b1ec7c4256fa8e0 + languageName: node + linkType: hard + "path-to-regexp@npm:0.1.7": version: 0.1.7 resolution: "path-to-regexp@npm:0.1.7" @@ -28524,6 +28631,13 @@ __metadata: languageName: node linkType: hard +"signal-exit@npm:^4.0.1": + version: 4.0.2 + resolution: "signal-exit@npm:4.0.2" + checksum: 41f5928431cc6e91087bf0343db786a6313dd7c6fd7e551dbc141c95bb5fb26663444fd9df8ea47c5d7fc202f60aa7468c3162a9365cbb0615fc5e1b1328fe31 + languageName: node + linkType: hard + "simple-git@npm:^3.6.0": version: 3.16.0 resolution: "simple-git@npm:3.16.0" @@ -29343,6 +29457,17 @@ __metadata: languageName: node linkType: hard +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.0.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.2, string-width@npm:^4.2.3": + version: 4.2.3 + resolution: "string-width@npm:4.2.3" + dependencies: + emoji-regex: ^8.0.0 + is-fullwidth-code-point: ^3.0.0 + strip-ansi: ^6.0.1 + checksum: e52c10dc3fbfcd6c3a15f159f54a90024241d0f149cf8aed2982a2d801d2e64df0bf1dc351cf8e95c3319323f9f220c16e740b06faecd53e2462df1d2b5443fb + languageName: node + linkType: hard + "string-width@npm:^1.0.1": version: 1.0.2 resolution: "string-width@npm:1.0.2" @@ -29354,17 +29479,6 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.0.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.2, string-width@npm:^4.2.3": - version: 4.2.3 - resolution: "string-width@npm:4.2.3" - dependencies: - emoji-regex: ^8.0.0 - is-fullwidth-code-point: ^3.0.0 - strip-ansi: ^6.0.1 - checksum: e52c10dc3fbfcd6c3a15f159f54a90024241d0f149cf8aed2982a2d801d2e64df0bf1dc351cf8e95c3319323f9f220c16e740b06faecd53e2462df1d2b5443fb - languageName: node - linkType: hard - "string-width@npm:^5.0.0": version: 5.0.1 resolution: "string-width@npm:5.0.1" @@ -29376,6 +29490,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^5.0.1, string-width@npm:^5.1.2": + version: 5.1.2 + resolution: "string-width@npm:5.1.2" + dependencies: + eastasianwidth: ^0.2.0 + emoji-regex: ^9.2.2 + strip-ansi: ^7.0.1 + checksum: 7369deaa29f21dda9a438686154b62c2c5f661f8dda60449088f9f980196f7908fc39fdd1803e3e01541970287cf5deae336798337e9319a7055af89dafa7193 + languageName: node + linkType: hard + "string.prototype.matchall@npm:^4.0.7, string.prototype.matchall@npm:^4.0.8": version: 4.0.8 resolution: "string.prototype.matchall@npm:4.0.8" @@ -29481,6 +29606,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": + version: 6.0.1 + resolution: "strip-ansi@npm:6.0.1" + dependencies: + ansi-regex: ^5.0.1 + checksum: f3cd25890aef3ba6e1a74e20896c21a46f482e93df4a06567cebf2b57edabb15133f1f94e57434e0a958d61186087b1008e89c94875d019910a213181a14fc8c + languageName: node + linkType: hard + "strip-ansi@npm:^3.0.0, strip-ansi@npm:^3.0.1": version: 3.0.1 resolution: "strip-ansi@npm:3.0.1" @@ -29490,15 +29624,6 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": - version: 6.0.1 - resolution: "strip-ansi@npm:6.0.1" - dependencies: - ansi-regex: ^5.0.1 - checksum: f3cd25890aef3ba6e1a74e20896c21a46f482e93df4a06567cebf2b57edabb15133f1f94e57434e0a958d61186087b1008e89c94875d019910a213181a14fc8c - languageName: node - linkType: hard - "strip-ansi@npm:^7.0.1": version: 7.0.1 resolution: "strip-ansi@npm:7.0.1" @@ -32075,6 +32200,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": + version: 7.0.0 + resolution: "wrap-ansi@npm:7.0.0" + dependencies: + ansi-styles: ^4.0.0 + string-width: ^4.1.0 + strip-ansi: ^6.0.0 + checksum: a790b846fd4505de962ba728a21aaeda189b8ee1c7568ca5e817d85930e06ef8d1689d49dbf0e881e8ef84436af3a88bc49115c2e2788d841ff1b8b5b51a608b + languageName: node + linkType: hard + "wrap-ansi@npm:^6.2.0": version: 6.2.0 resolution: "wrap-ansi@npm:6.2.0" @@ -32086,14 +32222,14 @@ __metadata: languageName: node linkType: hard -"wrap-ansi@npm:^7.0.0": - version: 7.0.0 - resolution: "wrap-ansi@npm:7.0.0" +"wrap-ansi@npm:^8.1.0": + version: 8.1.0 + resolution: "wrap-ansi@npm:8.1.0" dependencies: - ansi-styles: ^4.0.0 - string-width: ^4.1.0 - strip-ansi: ^6.0.0 - checksum: a790b846fd4505de962ba728a21aaeda189b8ee1c7568ca5e817d85930e06ef8d1689d49dbf0e881e8ef84436af3a88bc49115c2e2788d841ff1b8b5b51a608b + ansi-styles: ^6.1.0 + string-width: ^5.0.1 + strip-ansi: ^7.0.1 + checksum: 371733296dc2d616900ce15a0049dca0ef67597d6394c57347ba334393599e800bab03c41d4d45221b6bc967b8c453ec3ae4749eff3894202d16800fdfe0e238 languageName: node linkType: hard From ba97c492f94ee9c97d4feb8354f13434b0c7a7d0 Mon Sep 17 00:00:00 2001 From: Brendan O'Handley Date: Thu, 8 Jun 2023 12:53:17 -0400 Subject: [PATCH 03/51] Prometheus: Metrics explorer usability test improvements (#69528) * remove infer type functionality because usability tests confirmed it was confusing/not helpful * persist button option to open modal when typing in metric select * update copy desc for setting that includes type and description in search * when filtering by type, only return metrics with defined type * give focused metric row more contrast, consistent with metric select focused option * allow selection of metrics with unknown types and undefined types * add highlighting to backend search * augment counters created from summaries with (summary) * remove type from search input setting and only search by name and description * fix test to reflect that type has been removed from the metadata input search as duplicated by the filter * add button to select metric, change wording, make table hover row consistent with grafana table * add tooltip icon with docs link for metric types that are augmented, histogram and summary * remove files slated for future refactoring * style changes based on catherine's review * remove border from settings btn, select btn increase to md, change col size in table, fix responsive inputs ui for sm screens --- .../querybuilder/components/MetricSelect.tsx | 42 +++++-- .../metrics-modal/AdditionalSettings.tsx | 23 +--- .../metrics-modal/MetricsModal.test.tsx | 4 +- .../components/metrics-modal/MetricsModal.tsx | 80 +++++------- .../components/metrics-modal/ResultsTable.tsx | 115 +++++++++++++----- .../components/metrics-modal/state/helpers.ts | 72 ++++------- .../components/metrics-modal/state/state.ts | 6 - .../components/metrics-modal/styles.ts | 9 +- .../components/metrics-modal/types.ts | 1 - 9 files changed, 184 insertions(+), 168 deletions(-) diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx index 15545025545..98689be099d 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx @@ -49,6 +49,14 @@ export function MetricSelect({ initialMetrics?: string[]; }>({}); + const metricsModalOption: SelectableValue[] = [ + { + value: 'BrowseMetrics', + label: 'Metrics explorer', + description: 'Browse and filter metrics and metadata with a fuzzy search', + }, + ]; + const customFilterOption = useCallback((option: SelectableValue, searchQuery: string) => { const label = option.label ?? option.value; if (!label) { @@ -61,7 +69,16 @@ export function MetricSelect({ } const searchWords = searchQuery.split(splitSeparator); - return searchWords.reduce((acc, cur) => acc && label.toLowerCase().includes(cur.toLowerCase()), true); + return searchWords.reduce((acc, cur) => { + const matcheSearch = label.toLowerCase().includes(cur.toLowerCase()); + + let browseOption = false; + if (prometheusMetricEncyclopedia) { + browseOption = label === 'Metrics explorer'; + } + + return acc && (matcheSearch || browseOption); + }, true); }, []); const formatOptionLabel = useCallback( @@ -70,7 +87,8 @@ export function MetricSelect({ if (option['__isNew__']) { return option.label; } - + // only matches on input, does not match on regex + // look into matching for regex input return ( PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) { results.splice(0, results.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS); } - return results.map((result) => { + + const resultsOptions = results.map((result) => { return { label: result.text, value: result.text, }; }); + + if (prometheusMetricEncyclopedia) { + return [...metricsModalOption, ...resultsOptions]; + } else { + return resultsOptions; + } }); }; @@ -201,18 +226,11 @@ export function MetricSelect({ } if (prometheusMetricEncyclopedia) { - // pass the initial metrics, possibly filtered by labels into the Metrics Modal - const metricsModalOption: SelectableValue[] = [ - { - value: 'BrowseMetrics', - label: 'Metrics explorer', - description: 'Browse and filter metrics and metadata with a fuzzy search', - }, - ]; - // pass the initial metrics into the Metrics Modal setState({ + // add the modal butoon option to the options metrics: [...metricsModalOption, ...metrics], isLoading: undefined, + // pass the initial metrics into the Metrics Modal initialMetrics: initialMetrics, }); } else { diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/AdditionalSettings.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/AdditionalSettings.tsx index 3991277ed7a..b5331ef8d49 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/AdditionalSettings.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/AdditionalSettings.tsx @@ -14,18 +14,11 @@ type AdditionalSettingsProps = { onChangeIncludeNullMetadata: () => void; onChangeDisableTextWrap: () => void; onChangeUseBackend: () => void; - onChangeInferType: () => void; }; export function AdditionalSettings(props: AdditionalSettingsProps) { - const { - state, - onChangeFullMetaSearch, - onChangeIncludeNullMetadata, - onChangeDisableTextWrap, - onChangeUseBackend, - onChangeInferType, - } = props; + const { state, onChangeFullMetaSearch, onChangeIncludeNullMetadata, onChangeDisableTextWrap, onChangeUseBackend } = + props; const theme = useTheme2(); const styles = getStyles(theme); @@ -63,18 +56,6 @@ export function AdditionalSettings(props: AdditionalSettingsProps) { -
- onChangeInferType()} /> -
{placeholders.inferType} 
- - - -
); } diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.test.tsx index 94ec3155abc..cc0c82f3e8c 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.test.tsx @@ -159,7 +159,7 @@ describe('MetricsModal', () => { }); }); - it('searches by all metric metadata with a fuzzy search', async () => { + it('searches by name and description with a fuzzy search when setting is turned on', async () => { // search for a_bucket by metadata type counter but only type countt setup(defaultQuery, listOfMetrics); let metricABucket: HTMLElement | null; @@ -179,7 +179,7 @@ describe('MetricsModal', () => { const searchMetric = screen.getByTestId(testIds.searchMetric); expect(searchMetric).toBeInTheDocument(); - await userEvent.type(searchMetric, 'countt'); + await userEvent.type(searchMetric, 'functions'); await waitFor(() => { metricABucket = screen.getByText('a_bucket'); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx index 1e5895f21f0..f7786e3c3bd 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx @@ -69,7 +69,6 @@ const { setSelectedIdx, setDisableTextWrap, showAdditionalSettings, - setInferType, } = stateSlice.actions; export const MetricsModal = (props: MetricsModalProps) => { @@ -83,31 +82,26 @@ export const MetricsModal = (props: MetricsModalProps) => { /** * loads metrics and metadata on opening modal and switching off useBackend */ - const updateMetricsMetadata = useCallback( - async (inferType: boolean) => { - // *** Loading Gif - dispatch(setIsLoading(true)); + const updateMetricsMetadata = useCallback(async () => { + // *** Loading Gif + dispatch(setIsLoading(true)); - const data: MetricsModalMetadata = await setMetrics(datasource, query, inferType, initialMetrics); - - dispatch( - buildMetrics({ - isLoading: false, - hasMetadata: data.hasMetadata, - metrics: data.metrics, - metaHaystackDictionary: data.metaHaystackDictionary, - nameHaystackDictionary: data.nameHaystackDictionary, - totalMetricCount: data.metrics.length, - filteredMetricCount: data.metrics.length, - }) - ); - }, - [query, datasource, initialMetrics] - ); + const data: MetricsModalMetadata = await setMetrics(datasource, query, initialMetrics); + dispatch( + buildMetrics({ + isLoading: false, + hasMetadata: data.hasMetadata, + metrics: data.metrics, + metaHaystackDictionary: data.metaHaystackDictionary, + nameHaystackDictionary: data.nameHaystackDictionary, + totalMetricCount: data.metrics.length, + filteredMetricCount: data.metrics.length, + }) + ); + }, [query, datasource, initialMetrics]); useEffect(() => { - updateMetricsMetadata(state.inferType); - // eslint-disable-next-line react-hooks/exhaustive-deps + updateMetricsMetadata(); }, [updateMetricsMetadata]); const typeOptions: SelectableValue[] = promTypes.map((t: PromFilterOption) => { @@ -123,10 +117,10 @@ export const MetricsModal = (props: MetricsModalProps) => { */ const debouncedBackendSearch = useMemo( () => - debounce(async (metricText: string, inferType: boolean) => { + debounce(async (metricText: string) => { dispatch(setIsLoading(true)); - const metrics = await getBackendSearchMetrics(metricText, query.labels, datasource, inferType); + const metrics = await getBackendSearchMetrics(metricText, query.labels, datasource); dispatch( filterMetricsBackend({ @@ -150,9 +144,9 @@ export const MetricsModal = (props: MetricsModalProps) => { function searchCallback(query: string, fullMetaSearchVal: boolean) { if (state.useBackend && query === '') { // get all metrics data if a user erases everything in the input - updateMetricsMetadata(state.inferType); + updateMetricsMetadata(); } else if (state.useBackend) { - debouncedBackendSearch(query, state.inferType); + debouncedBackendSearch(query); } else { // search either the names or all metadata // fuzzy search go! @@ -200,31 +194,17 @@ export const MetricsModal = (props: MetricsModalProps) => { onChange({ ...query, disableTextWrap: !state.disableTextWrap }); tracking('grafana_prom_metric_encycopedia_disable_text_wrap_interaction', state, ''); }} - onChangeInferType={() => { - const inferType = !state.inferType; - dispatch(setInferType(inferType)); - // update the type - if (state.useBackend) { - // if there is no query yet, it will infer the type on the api call - if (state.fuzzySearchQuery !== '') { - debouncedBackendSearch(state.fuzzySearchQuery, inferType); - } - } else { - // updates the metadata with the inferred type - updateMetricsMetadata(inferType); - } - }} onChangeUseBackend={() => { const newVal = !state.useBackend; dispatch(setUseBackend(newVal)); onChange({ ...query, useBackend: newVal }); if (newVal === false) { // rebuild the metrics metadata if we turn off useBackend - updateMetricsMetadata(state.inferType); + updateMetricsMetadata(); } else { // check if there is text in the browse search and update if (state.fuzzySearchQuery !== '') { - debouncedBackendSearch(state.fuzzySearchQuery, state.inferType); + debouncedBackendSearch(state.fuzzySearchQuery); } // otherwise wait for user typing } @@ -259,9 +239,6 @@ export const MetricsModal = (props: MetricsModalProps) => { }} /> -
- -
{state.hasMetadata && (
{ />
)} +
+ +
{ size="md" onClick={() => dispatch(showAdditionalSettings())} data-testid={testIds.showAdditionalSettings} + className={styles.noBorder} > Additional Settings -
@@ -368,5 +353,4 @@ export const testIds = { resultsPerPage: 'results-per-page', setUseBackend: 'set-use-backend', showAdditionalSettings: 'show-additional-settings', - inferType: 'set-infer-type', }; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx index c2cdbfd2187..367059af361 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx @@ -3,8 +3,9 @@ import React, { ReactElement, useEffect, useRef } from 'react'; import Highlighter from 'react-highlight-words'; import { GrafanaTheme2 } from '@grafana/data'; -import { Icon, Tooltip, useTheme2 } from '@grafana/ui'; +import { Button, Icon, Tooltip, useTheme2 } from '@grafana/ui'; +import { docsTip } from '../../../configuration/ConfigEditor'; import { PromVisualQuery } from '../../types'; import { tracking } from './state/helpers'; @@ -51,15 +52,7 @@ export function ResultsTable(props: ResultsTableProps) { if (state.fullMetaSearch && metric) { return ( <> - - {' '} - {inferredType(metric.inferred ?? false)} - + {displayType(metric.type ?? '')} - - {metric.type ?? ''} {inferredType(metric.inferred ?? false)} - + {displayType(metric.type ?? '')} {metric.description ?? ''} ); } } - function inferredType(inferred: boolean): JSX.Element | undefined { - if (inferred) { - return ( - - - - ); - } else { - return undefined; + function addHelpIcon(fullType: string, descriptiveType: string, link: string) { + return ( + <> + {fullType} + + + When creating a {descriptiveType}, Prometheus exposes multiple series with the type counter.{' '} + {docsTip(link)} + + } + placement="bottom-start" + interactive={true} + > + + + + + ); + } + + function displayType(type: string | null) { + if (!type) { + return ''; } + + if (type.includes('(summary)')) { + return addHelpIcon(type, 'summary', 'https://prometheus.io/docs/concepts/metric_types/#summary'); + } + + if (type.includes('(histogram)')) { + return addHelpIcon(type, 'histogram', 'https://prometheus.io/docs/concepts/metric_types/#histogram'); + } + + return type; } function noMetricsMessages(): ReactElement { @@ -105,7 +122,7 @@ export function ResultsTable(props: ResultsTableProps) { message = 'There are no metrics found. Try to expand your label filters.'; } - if (state.fuzzySearchQuery) { + if (state.fuzzySearchQuery || state.selectedTypes.length > 0) { message = 'There are no metrics found. Try to expand your search and filters.'; } @@ -116,6 +133,21 @@ export function ResultsTable(props: ResultsTableProps) { ); } + function textHighlight(state: MetricsModalState) { + if (state.useBackend) { + // highlight the input only for the backend search + // this highlight is equivalent to how the metric select highlights + // look into matching on regex input + return [state.fuzzySearchQuery]; + } else if (state.fullMetaSearch) { + // highlight the matches in the ufuzzy metaHaystack + return state.metaHaystackMatches; + } else { + // highlight the ufuzzy name matches + return state.nameHaystackMatches; + } + } + return ( @@ -124,9 +156,10 @@ export function ResultsTable(props: ResultsTableProps) { {state.hasMetadata && ( <> - + )} + @@ -137,8 +170,6 @@ export function ResultsTable(props: ResultsTableProps) { selectMetric(metric)} - tabIndex={0} onFocus={() => onFocusRow(idx)} onKeyDown={(e) => { if (e.code === 'Enter' && e.currentTarget.classList.contains('selected-row')) { @@ -149,12 +180,22 @@ export function ResultsTable(props: ResultsTableProps) { {state.hasMetadata && metaRows(metric)} + ); })} @@ -186,7 +227,6 @@ const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { `, row: css` label: row; - cursor: pointer; border-bottom: 1px solid ${theme.colors.border.weak} &:last-child { border-bottom: 0; @@ -207,13 +247,19 @@ const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { background-color: ${theme.components.textHighlight.background}; `, nameWidth: css` - ${disableTextWrap ? '' : 'width: 40%;'} + ${disableTextWrap ? '' : 'width: 37.5%;'} `, nameOverflow: css` ${disableTextWrap ? '' : 'overflow-wrap: anywhere;'} `, typeWidth: css` - ${disableTextWrap ? '' : 'width: 16%;'} + ${disableTextWrap ? '' : 'width: 15%;'} + `, + descriptionWidth: css` + ${disableTextWrap ? '' : 'width: 35%;'} + `, + selectButtonWidth: css` + ${disableTextWrap ? '' : 'width: 12.5%;'} `, stickyHeader: css` position: sticky; @@ -224,8 +270,13 @@ const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { text-align: center; color: ${theme.colors.text.secondary}; `, - italicized: css` - font-style: italic; + tooltipSpace: css` + margin-left: 4px; + `, + centerButton: css` + display: block; + margin: auto; + border: none; `, }; }; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts index ef6b7108795..e384c3083c1 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts @@ -16,7 +16,6 @@ const { setFilteredMetricCount } = stateSlice.actions; export async function setMetrics( datasource: PrometheusDatasource, query: PromVisualQuery, - inferType: boolean, initialMetrics?: string[] ): Promise { // metadata is set in the metric select now @@ -34,9 +33,9 @@ export async function setMetrics( let metricsData: MetricsData | undefined; metricsData = initialMetrics?.map((m: string) => { - const metricData = buildMetricData(m, inferType, datasource); + const metricData = buildMetricData(m, datasource); - const metaDataString = `${m}¦${metricData.type}¦${metricData.description}`; + const metaDataString = `${m}¦${metricData.description}`; nameHaystackDictionaryData[m] = metricData; metaHaystackDictionaryData[metaDataString] = metricData; @@ -56,34 +55,27 @@ export async function setMetrics( } /** - * Builds the metric data object with type, description and inferred flag + * Builds the metric data object with type and description * * @param metric The metric name - * @param inferType state attribute that the infer type setting is on or off * @param datasource The Prometheus datasource for mapping metradata to the metric name * @returns A MetricData object. */ -function buildMetricData(metric: string, inferType: boolean, datasource: PrometheusDatasource): MetricData { +function buildMetricData(metric: string, datasource: PrometheusDatasource): MetricData { let type = getMetadataType(metric, datasource.languageProvider.metricsMetadata!); - let inferredType; - if (!type && inferType) { - type = metricTypeHints(metric); - if (type) { - inferredType = true; - } - } const description = getMetadataHelp(metric, datasource.languageProvider.metricsMetadata!); - if (description?.toLowerCase().includes('histogram') && type !== 'histogram') { - type += ' (histogram)'; - } + ['histogram', 'summary'].forEach((t) => { + if (description?.toLowerCase().includes(t) && type !== t) { + type += ` (${t})`; + } + }); const metricData: MetricData = { value: metric, type: type, description: description, - inferred: inferredType, }; return metricData; @@ -123,22 +115,21 @@ export function filterMetrics(state: MetricsModalState): MetricsData { if (m.type && t.value) { return m.type.includes(t.value); } + + if (!m.type && t.value === 'no type') { + return true; + } + return false; }); - // missing type - const hasNoType = !m.type; - - return matchesSelectedType || (hasNoType && state.includeNullMetadata); + // when a user filters for type, only return metrics with defined types + return matchesSelectedType; }); } if (!state.includeNullMetadata) { filteredMetrics = filteredMetrics.filter((m: MetricData) => { - if (state.inferType && m.inferred) { - return true; - } - return m.type !== undefined && m.description !== undefined; }); } @@ -188,8 +179,7 @@ export const calculateResultsPerPage = (results: number, defaultResults: number, export async function getBackendSearchMetrics( metricText: string, labels: QueryBuilderLabelFilter[], - datasource: PrometheusDatasource, - inferType: boolean + datasource: PrometheusDatasource ): Promise> { const queryString = regexifyLabelValuesQueryString(metricText); @@ -202,24 +192,10 @@ export async function getBackendSearchMetrics( const results = datasource.metricFindQuery(params); return await results.then((results) => { - return results.map((result) => buildMetricData(result.text, inferType, datasource)); + return results.map((result) => buildMetricData(result.text, datasource)); }); } -function metricTypeHints(metric: string): string { - const histogramMetric = metric.match(/^\w+_bucket$|^\w+_bucket{.*}$/); - if (histogramMetric) { - return 'counter (histogram)'; - } - - const counterMatch = metric.match(/\b(\w+_(total|sum|count))\b/); - if (counterMatch) { - return 'counter'; - } - - return ''; -} - export function tracking(event: string, state?: MetricsModalState | null, metric?: string, query?: PromVisualQuery) { switch (event) { case 'grafana_prom_metric_encycopedia_tracking': @@ -230,7 +206,6 @@ export function tracking(event: string, state?: MetricsModalState | null, metric fuzzySearchQuery: state?.fuzzySearchQuery, fullMetaSearch: state?.fullMetaSearch, selectedTypes: state?.selectedTypes, - inferType: state?.inferType, }); case 'grafana_prom_metric_encycopedia_disable_text_wrap_interaction': reportInteraction(event, { @@ -263,13 +238,20 @@ export const promTypes: PromFilterOption[] = [ description: 'A summary samples observations (usually things like request durations and response sizes) and can calculate configurable quantiles over a sliding time window.', }, + { + value: 'unknown', + description: 'These metrics have been given the type unknown in the metadata.', + }, + { + value: 'no type', + description: 'These metrics have no defined type in the metadata.', + }, ]; export const placeholders = { browse: 'Search metrics by name', - metadataSearchSwitch: 'Include search with type and description', + metadataSearchSwitch: 'Include description in search', type: 'Filter by type', includeNullMetadata: 'Include results with no metadata', setUseBackend: 'Enable regex search', - inferType: 'Infer metric type', }; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/state.ts b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/state.ts index 7e6749a481a..c56b3d77457 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/state.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/state.ts @@ -84,9 +84,6 @@ export const stateSlice = createSlice({ showAdditionalSettings: (state) => { state.showAdditionalSettings = !state.showAdditionalSettings; }, - setInferType: (state, action: PayloadAction) => { - state.inferType = action.payload; - }, }, }); @@ -117,7 +114,6 @@ export function initialState(query?: PromVisualQuery): MetricsModalState { disableTextWrap: query?.disableTextWrap ?? false, selectedIdx: 0, showAdditionalSettings: false, - inferType: true, }; } @@ -171,8 +167,6 @@ export interface MetricsModalState { selectedIdx: number; /** Display toggle switches for settings */ showAdditionalSettings: boolean; - /** Check metric to match on substrings to infer prometheus type */ - inferType: boolean; } /** diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/styles.ts b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/styles.ts index bd5ad66f45e..e8eb2fe86dd 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/styles.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/styles.ts @@ -17,10 +17,14 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { display: flex; flex-direction: row; flex-wrap: wrap; - gap: ${theme.spacing(2)}; `, inputItemFirst: css` flex-basis: 40%; + padding-right: 16px; + ${theme.breakpoints.down('md')} { + padding-right: 0px; + padding-bottom: 16px; + } `, inputItem: css` flex-grow: 1; @@ -80,6 +84,9 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { settingsBtn: css` float: right; `, + noBorder: css` + border: none; + `, resultsPerPageLabel: css` color: ${theme.colors.text.secondary}; opacity: 75%; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/types.ts b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/types.ts index 2f25a7d49f5..d4be9a48638 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/types.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/types.ts @@ -4,7 +4,6 @@ export type MetricData = { value: string; type?: string | null; description?: string; - inferred?: boolean; }; export type PromFilterOption = { From 9b12d83b66d089000b9dc60be0f23d5f51d88716 Mon Sep 17 00:00:00 2001 From: Kim Nylander <104772500+knylander-grafana@users.noreply.github.com> Date: Thu, 8 Jun 2023 13:09:37 -0400 Subject: [PATCH 04/51] [DOC][Traces] Add second page where include file is called (#69814) * Add second page where include file is called * Updates from prettier --- docs/sources/shared/datasources/tempo-search-traceql.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/sources/shared/datasources/tempo-search-traceql.md b/docs/sources/shared/datasources/tempo-search-traceql.md index 7c177456edf..2601db49930 100644 --- a/docs/sources/shared/datasources/tempo-search-traceql.md +++ b/docs/sources/shared/datasources/tempo-search-traceql.md @@ -5,6 +5,8 @@ headless: true [//]: # 'This file documents the Search query type for the Tempo data source. It is available as a public preview.' [//]: # 'This shared file is included in these locations:' [//]: # '/grafana/docs/sources/datasources/tempo/query-editor/index.md' +[//]: # '/website/docs/grfana-cloud/data-configuration/traces/traces-query-editor.md' +[//]: # [//]: # 'If you make changes to this file, verify that the meaning and content are not changed in any place where the file is included.' [//]: # 'Any links should be fully qualified and not relative: /docs/grafana/ instead of ../grafana/.' From 0aef39d76adcb8fb4433897e2068871fc67aebc1 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu, 8 Jun 2023 19:58:02 +0200 Subject: [PATCH 05/51] Variables: Show description instead of definition in table (#69786) * Variables: Show description instead of definition in table * Remove referencing of definition * Update --- .../src/selectors/pages.ts | 3 ++- .../variables/editor/VariableEditorList.tsx | 2 +- .../editor/VariableEditorListRow.tsx | 26 ++++--------------- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index c0f2f36bbf5..1d6768ce36d 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -114,7 +114,8 @@ export const Pages = { newButton: 'Variable editor New variable button', table: 'Variable editor Table', tableRowNameFields: (variableName: string) => `Variable editor Table Name field ${variableName}`, - tableRowDefinitionFields: (variableName: string) => `Variable editor Table Definition field ${variableName}`, + tableRowDescriptionFields: (variableName: string) => + `Variable editor Table Description field ${variableName}`, tableRowArrowUpButtons: (variableName: string) => `Variable editor Table ArrowUp button ${variableName}`, tableRowArrowDownButtons: (variableName: string) => `Variable editor Table ArrowDown button ${variableName}`, tableRowDuplicateButtons: (variableName: string) => `Variable editor Table Duplicate button ${variableName}`, diff --git a/public/app/features/variables/editor/VariableEditorList.tsx b/public/app/features/variables/editor/VariableEditorList.tsx index 26b0723e688..5a5fe5a28cd 100644 --- a/public/app/features/variables/editor/VariableEditorList.tsx +++ b/public/app/features/variables/editor/VariableEditorList.tsx @@ -59,7 +59,7 @@ export function VariableEditorList({ - + diff --git a/public/app/features/variables/editor/VariableEditorListRow.tsx b/public/app/features/variables/editor/VariableEditorListRow.tsx index 5b378bb0afb..95a1bdb16e4 100644 --- a/public/app/features/variables/editor/VariableEditorListRow.tsx +++ b/public/app/features/variables/editor/VariableEditorListRow.tsx @@ -7,7 +7,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; import { Button, Icon, IconButton, useStyles2, useTheme2 } from '@grafana/ui'; -import { hasOptions, isAdHoc, isQuery } from '../guard'; +import { isAdHoc } from '../guard'; import { VariableUsagesButton } from '../inspect/VariableUsagesButton'; import { getVariableUsages, UsagesToNetwork, VariableUsageTree } from '../inspect/utils'; import { KeyedVariableIdentifier } from '../state/types'; @@ -35,7 +35,6 @@ export function VariableEditorListRow({ }: VariableEditorListRowProps): ReactElement { const theme = useTheme2(); const styles = useStyles2(getStyles); - const definition = getDefinition(variable); const usages = getVariableUsages(variable.id, usageTree); const passed = usages > 0 || isAdHoc(variable); const identifier = toKeyedVariableIdentifier(variable); @@ -68,14 +67,14 @@ export function VariableEditorListRow({
TypeDescriptionDescription
+ +
VariableDefinitionDescription
{ event.preventDefault(); propsOnEdit(identifier); }} - aria-label={selectors.pages.Dashboard.Settings.Variables.List.tableRowDefinitionFields(variable.name)} + aria-label={selectors.pages.Dashboard.Settings.Variables.List.tableRowDescriptionFields(variable.name)} > - {definition} + {variable.description} @@ -122,21 +121,6 @@ export function VariableEditorListRow({ ); } -function getDefinition(model: VariableModel): string { - let definition = ''; - if (isQuery(model)) { - if (model.definition) { - definition = model.definition; - } else if (typeof model.query === 'string') { - definition = model.query; - } - } else if (hasOptions(model)) { - definition = model.query; - } - - return definition; -} - interface VariableCheckIndicatorProps { passed: boolean; } @@ -174,7 +158,7 @@ function getStyles(theme: GrafanaTheme2) { cursor: pointer; color: ${theme.colors.primary.text}; `, - definitionColumn: css` + descriptionColumn: css` width: 100%; max-width: 200px; cursor: pointer; From 268d09affda1779a410948354d0480141a1a5cc8 Mon Sep 17 00:00:00 2001 From: Isabella Siu Date: Thu, 8 Jun 2023 14:45:29 -0400 Subject: [PATCH 06/51] CloudWatch Logs: Create monarch language syntax (#69741) --- .../cloudwatch/language/logs/definition.ts | 10 + .../cloudwatch/language/logs/language.ts | 181 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 public/app/plugins/datasource/cloudwatch/language/logs/definition.ts create mode 100644 public/app/plugins/datasource/cloudwatch/language/logs/language.ts diff --git a/public/app/plugins/datasource/cloudwatch/language/logs/definition.ts b/public/app/plugins/datasource/cloudwatch/language/logs/definition.ts new file mode 100644 index 00000000000..7170f4be9a0 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/language/logs/definition.ts @@ -0,0 +1,10 @@ +import { LanguageDefinition } from '../monarch/register'; + +const cloudWatchLogsLanguageDefinition: LanguageDefinition = { + id: 'logs', + extensions: [], + aliases: [], + mimetypes: [], + loader: () => import('./language'), +}; +export default cloudWatchLogsLanguageDefinition; diff --git a/public/app/plugins/datasource/cloudwatch/language/logs/language.ts b/public/app/plugins/datasource/cloudwatch/language/logs/language.ts new file mode 100644 index 00000000000..359b9d391e2 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/language/logs/language.ts @@ -0,0 +1,181 @@ +import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api'; + +// CloudWatch Logs: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_QuerySyntax.html +interface CloudWatchLogsLanguage extends monacoType.languages.IMonarchLanguage { + commands: string[]; + operators: string[]; + builtinFunctions: string[]; +} + +export const DISPLAY = 'display'; +export const FIELDS = 'fields'; +export const FILTER = 'filter'; +export const STATS = 'stats'; +export const SORT = 'sort'; +export const LIMIT = 'limit'; +export const PARSE = 'parse'; +export const UNMASK = 'unmask'; //make sure we support this one +export const LOGS_COMMANDS = [DISPLAY, FIELDS, FILTER, STATS, SORT, LIMIT, PARSE, UNMASK]; + +export const LOGS_LOGIC_OPERATORS = ['and', 'or', 'not']; + +export const LOGS_FUNCTION_OPERATORS = [ + // math + 'abs', + 'ceil', + 'floor', + 'greatest', + 'least', + 'log', + 'sqrt', + // datetime + 'bin', + 'datefloor', + 'dateceil', + 'fromMillis', + 'toMillis', + // general + 'ispresent', + 'coalesce', + // ip + 'isValidIp', + 'isValidIpV4', + 'isValidIpV6', + 'isIpInSubnet', + 'isIpv4InSubnet', + 'isIpv6InSubnet', + // stats aggregation + 'avg', + 'count', + 'count_distinct', + 'max', + 'min', + 'pct', + 'stddev', + 'sum', + // stats non-aggregation + 'earliest', + 'latest', + 'sortsFirst', + 'sortsLast', + // strings + 'isempty', + 'isblank', + 'concat', + 'ltrim', + 'rtrim', + 'trim', + 'strlen', + 'toupper', + 'tolower', + 'substr', + 'replace', + 'strcontains', +]; + +export const LOGS_KEYWORDS = ['like', 'by', 'in', 'desc', 'asc', 'as']; + +export const language: CloudWatchLogsLanguage = { + defaultToken: 'invalid', + id: 'logs', + ignoreCase: true, + brackets: [ + { open: '[', close: ']', token: 'delimiter.square' }, + { open: '(', close: ')', token: 'delimiter.parenthesis' }, + ], + commands: [...LOGS_COMMANDS, ...LOGS_KEYWORDS], + operators: LOGS_LOGIC_OPERATORS, + builtinFunctions: LOGS_FUNCTION_OPERATORS, + tokenizer: { + root: [ + { include: '@comments' }, + { include: '@regexes' }, + { include: '@whitespace' }, + { include: '@fieldNames' }, + { include: '@variables' }, + { include: '@strings' }, + { include: '@numbers' }, + + [/\|\|/, 'operator'], + [/[,.:\|]/, 'delimiter'], + [/[()\[\]]/, 'delimiter.parenthesis'], + [ + /[\w@#$]+/, + { + cases: { + '@commands': 'keyword', + '@builtinFunctions': 'predefined', + '@operators': 'operator', + '@default': 'identifier', + }, + }, + ], + [/[+\-*/^%=!<>]/, 'operator'], // handles the math operators + ], + variables: [ + [/\${/, { token: 'variable', next: '@variable_bracket' }], + [/\$[a-zA-Z0-9-_]+/, 'variable'], + ], + variable_bracket: [ + [/[a-zA-Z0-9-_:]+/, 'variable'], + [/}/, { token: 'variable', next: '@pop' }], + ], + fieldNames: [[/(@[_a-zA-Z]+[_.0-9a-zA-Z]*)|(`((\\`)|([^`]))*?`)/, 'identifier']], + whitespace: [[/\s+/, 'white']], + comments: [ + [/^#.*/, 'comment'], + [/\s+#.*/, 'comment'], + ], + numbers: [ + [/0[xX][0-9a-fA-F]*/, 'number'], + [/[$][+-]*\d*(\.\d*)?/, 'number'], + [/((\d+(\.\d*)?)|(\.\d+))([eE][\-+]?\d+)?/, 'number'], + ], + strings: [ + [/'/, { token: 'string', next: '@string' }], + [/"/, { token: 'string', next: '@string_double' }], + [/`/, { token: 'identifier', next: '@string_backtick' }], + ], + string: [ + [/[^']+/, 'string'], + [/''/, 'string'], + [/'/, { token: 'string', next: '@pop' }], + ], + string_double: [ + [/[^\\"]+/, 'string'], + [/"/, 'string', '@pop'], + ], + string_backtick: [ + [/[^\\`]+/, 'identifier'], + [/`/, 'identifier', '@pop'], + ], + regexes: [[/\/.*?\/(?=\s*\||\s*$|,)/, 'regexp']], + }, +}; + +export const conf: monacoType.languages.LanguageConfiguration = { + comments: { + lineComment: '#', + }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + { open: '`', close: '`' }, + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + { open: '`', close: '`' }, + ], +}; From 387cf7ec60608dfb1ca66a39015f812b675c572b Mon Sep 17 00:00:00 2001 From: Kevin Yu Date: Thu, 8 Jun 2023 14:44:28 -0700 Subject: [PATCH 07/51] CloudWatch: Add missing AWS/FSx metrics (#69816) --- pkg/tsdb/cloudwatch/constants/metrics.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tsdb/cloudwatch/constants/metrics.go b/pkg/tsdb/cloudwatch/constants/metrics.go index 0130423d96e..36fbd9dbb2b 100644 --- a/pkg/tsdb/cloudwatch/constants/metrics.go +++ b/pkg/tsdb/cloudwatch/constants/metrics.go @@ -333,7 +333,7 @@ var NamespaceMetricsMap = map[string][]string{ "AWS/ElasticMapReduce": {"AppsCompleted", "AppsFailed", "AppsKilled", "AppsPending", "AppsRunning", "AppsSubmitted", "BackupFailed", "CapacityRemainingGB", "Cluster Status", "ContainerAllocated", "ContainerPending", "ContainerPendingRatio", "ContainerReserved", "CoreNodesPending", "CoreNodesRunning", "CorruptBlocks", "DfsPendingReplicationBlocks", "HBase", "HDFSBytesRead", "HDFSBytesWritten", "HDFSUtilization", "HbaseBackupFailed", "IO", "IsIdle", "JobsFailed", "JobsRunning", "LiveDataNodes", "LiveTaskTrackers", "MRActiveNodes", "MRDecommissionedNodes", "MRLostNodes", "MRRebootedNodes", "MRTotalNodes", "MRUnhealthyNodes", "Map/Reduce", "MapSlotsOpen", "MapTasksRemaining", "MapTasksRunning", "MemoryAllocatedMB", "MemoryAvailableMB", "MemoryReservedMB", "MemoryTotalMB", "MissingBlocks", "MostRecentBackupDuration", "Node Status", "PendingDeletionBlocks", "ReduceSlotsOpen", "ReduceTasksRemaining", "ReduceTasksRunning", "RemainingMapTasksPerSlot", "S3BytesRead", "S3BytesWritten", "TaskNodesPending", "TaskNodesRunning", "TimeSinceLastSuccessfulBackup", "TotalLoad", "UnderReplicatedBlocks", "YARNMemoryAvailablePercentage"}, "AWS/ElasticTranscoder": {"Billed Audio Output", "Billed HD Output", "Billed SD Output", "Errors", "Jobs Completed", "Jobs Errored", "Outputs per Job", "Standby Time", "Throttles"}, "AWS/Events": {"DeadLetterInvocations", "Events", "FailedInvocations", "IngestionToInvocationStartLatency", "Invocations", "InvocationsFailedToBeSentToDlq", "InvocationsSentToDlq", "MatchedEvents", "ThrottledRules", "TriggeredRules"}, - "AWS/FSx": {"DataReadBytes", "DataReadOperations", "DataWriteBytes", "DataWriteOperations", "FreeDataStorageCapacity", "FreeStorageCapacity", "MetadataOperations"}, + "AWS/FSx": {"ClientConnections", "CPUUtilization", "DataReadBytes", "DataReadOperations", "DataWriteBytes", "DataWriteOperations", "DeduplicationSavedStorage", "DiskIopsUtilization", "DiskReadBytes", "DiskReadOperations", "DiskThroughputBalance", "DiskThroughputUtilization", "DiskWriteBytes", "DiskWriteOperations", "FileServerDiskIopsBalance", "FileServerDiskIopsUtilization", "FileServerDiskThroughputBalance", "FileServerDiskThroughputUtilization", "FreeDataStorageCapacity", "FreeStorageCapacity", "MemoryUtilization", "MetadataOperations", "NetworkThroughputUtilization", "StorageCapacityUtilization"}, "AWS/Firehose": {"BackupToS3.Bytes", "BackupToS3.DataFreshness", "BackupToS3.Records", "BackupToS3.Success", "DataReadFromKinesisStream.Bytes", "DataReadFromKinesisStream.Records", "DeliveryToElasticsearch.Bytes", "DeliveryToElasticsearch.Records", "DeliveryToElasticsearch.Success", "DeliveryToRedshift.Bytes", "DeliveryToRedshift.Records", "DeliveryToRedshift.Success", "DeliveryToS3.Bytes", "DeliveryToS3.DataFreshness", "DeliveryToS3.Records", "DeliveryToS3.Success", "DeliveryToSplunk.Bytes", "DeliveryToSplunk.DataFreshness", "DeliveryToSplunk.Records", "DeliveryToSplunk.Success", "DescribeDeliveryStream.Latency", "DescribeDeliveryStream.Requests", "ExecuteProcessing.Duration", "ExecuteProcessing.Success", "FailedConversion.Bytes", "FailedConversion.Records", "IncomingBytes", "IncomingRecords", "KinesisMillisBehindLatest", "ListDeliveryStreams.Latency", "ListDeliveryStreams.Requests", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Requests", "PutRecordBatch.Bytes", "PutRecordBatch.Latency", "PutRecordBatch.Records", "PutRecordBatch.Requests", "SucceedConversion.Bytes", "SucceedConversion.Records", "SucceedProcessing.Bytes", "SucceedProcessing.Records", "ThrottledDescribeStream", "ThrottledGetRecords", "ThrottledGetShardIterator", "UpdateDeliveryStream.Latency", "UpdateDeliveryStream.Requests"}, "AWS/FraudDetector": {"GetEventPrediction", "GetEventPrediction4xxError", "GetEventPrediction5xxError", "GetEventPredictionLatency", "ModelInvocation", "ModelInvocationError", "ModelInvocationLatency", "OutcomeReturned", "Prediction", "PredictionError", "PredictionLatency", "RuleEvaluateError", "RuleEvaluateFalse", "RuleEvaluateTrue", "RuleNotEvaluated", "VariableDefaultReturned", "VariableUsed"}, "AWS/GameLift": {"ActivatingGameSessions", "ActiveGameSessions", "ActiveInstances", "ActiveServerProcesses", "AvailableGameSessions", "AverageWaitTime", "CurrentPlayerSessions", "CurrentTickets", "DesiredInstances", "FirstChoiceNotViable", "FirstChoiceOutOfCapacity", "GameSessionInterruptions", "HealthyServerProcesses", "IdleInstances", "InstanceInterruptions", "LowestLatencyPlacement", "LowestPricePlacement", "MatchAcceptancesTimedOut", "MatchesAccepted", "MatchesCreated", "MatchesPlaced", "MatchesRejected", "MaxInstances", "MinInstances", "PercentAvailableGameSessions", "PercentHealthyServerProcesses", "PercentIdleInstances", "Placement", "PlacementsCanceled", "PlacementsFailed", "PlacementsStarted", "PlacementsSucceeded", "PlacementsTimedOut", "PlayerSessionActivations", "PlayersStarted", "QueueDepth", "RuleEvaluationsFailed", "RuleEvaluationsPassed", "ServerProcessAbnormalTerminations", "ServerProcessActivations", "ServerProcessTerminations", "TicketsFailed", "TicketsStarted", "TicketsTimedOut", "TimeToMatch", "TimeToTicketSuccess"}, From 0c688190f7408982598995ebea27f5a6e2616862 Mon Sep 17 00:00:00 2001 From: Matthew Jacobson Date: Thu, 8 Jun 2023 18:51:50 -0400 Subject: [PATCH 08/51] Alerting: Fix unique violation when updating rule group with title chains/cycles (#67868) * Alerting: Fix unique violation when updating rule group with title chains/cycles The uniqueness constraint for titles within an org+folder is enforced on every update within a transaction instead of on commit (deferred constraint). This means that there could be a set of updates that will throw a unique constraint violation in an intermediate step even though the final state is valid. For example, a chain of updates RuleA -> RuleB -> RuleC could fail if not executed in the correct order, or a swap of titles RuleA <-> RuleB cannot be executed in any order without violating the constraint. The exact solution to this is complex and requires determining directed paths and cycles in the update graph, adding in temporary updates to break cycles, and then executing the updates in reverse topological order (see first commit in PR if curious). This is not implemented here. Instead, we choose a simpler solution that works in all cases but might perform more updates than necessary. This simpler solution makes a determination of whether an intermediate collision could occur and if so, adds a temporary title on all updated rules to break any cycles and remove the need for specific ordering. In addition, we make sure diffs are executed in the following order: DELETES, UPDATES, INSERTS. --- pkg/services/ngalert/api/api_ruler.go | 50 ++-- .../ngalert/provisioning/alert_rules.go | 75 +++--- .../ngalert/provisioning/alert_rules_test.go | 204 +++++++++++++++ pkg/services/ngalert/store/alert_rule.go | 78 ++++++ pkg/services/ngalert/store/alert_rule_test.go | 236 +++++++++++++++++- pkg/tests/api/alerting/api_ruler_test.go | 35 ++- 6 files changed, 613 insertions(+), 65 deletions(-) diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index df2897208d7..fa6d6cde63f 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -350,29 +350,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey finalChanges = store.UpdateCalculatedRuleFields(groupChanges) logger.Debug("updating database with the authorized changes", "add", len(finalChanges.New), "update", len(finalChanges.New), "delete", len(finalChanges.Delete)) - if len(finalChanges.Update) > 0 || len(finalChanges.New) > 0 { - updates := make([]ngmodels.UpdateRule, 0, len(finalChanges.Update)) - inserts := make([]ngmodels.AlertRule, 0, len(finalChanges.New)) - for _, update := range finalChanges.Update { - logger.Debug("updating rule", "rule_uid", update.New.UID, "diff", update.Diff.String()) - updates = append(updates, ngmodels.UpdateRule{ - Existing: update.Existing, - New: *update.New, - }) - } - for _, rule := range finalChanges.New { - inserts = append(inserts, *rule) - } - _, err = srv.store.InsertAlertRules(tranCtx, inserts) - if err != nil { - return fmt.Errorf("failed to add rules: %w", err) - } - err = srv.store.UpdateAlertRules(tranCtx, updates) - if err != nil { - return fmt.Errorf("failed to update rules: %w", err) - } - } - + // Delete first as this could prevent future unique constraint violations. if len(finalChanges.Delete) > 0 { UIDs := make([]string, 0, len(finalChanges.Delete)) for _, rule := range finalChanges.Delete { @@ -384,6 +362,32 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey } } + if len(finalChanges.Update) > 0 { + updates := make([]ngmodels.UpdateRule, 0, len(finalChanges.Update)) + for _, update := range finalChanges.Update { + logger.Debug("updating rule", "rule_uid", update.New.UID, "diff", update.Diff.String()) + updates = append(updates, ngmodels.UpdateRule{ + Existing: update.Existing, + New: *update.New, + }) + } + err = srv.store.UpdateAlertRules(tranCtx, updates) + if err != nil { + return fmt.Errorf("failed to update rules: %w", err) + } + } + + if len(finalChanges.New) > 0 { + inserts := make([]ngmodels.AlertRule, 0, len(finalChanges.New)) + for _, rule := range finalChanges.New { + inserts = append(inserts, *rule) + } + _, err = srv.store.InsertAlertRules(tranCtx, inserts) + if err != nil { + return fmt.Errorf("failed to add rules: %w", err) + } + } + if len(finalChanges.New) > 0 { limitReached, err := srv.QuotaService.CheckQuotaReached(tranCtx, ngmodels.QuotaTargetSrv, "a.ScopeParameters{ OrgID: c.OrgID, diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index b673be172f0..049035f584d 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -260,53 +260,60 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, orgID int } return service.xact.InTransaction(ctx, func(ctx context.Context) error { - uids, err := service.ruleStore.InsertAlertRules(ctx, withoutNilAlertRules(delta.New)) - if err != nil { - return fmt.Errorf("failed to insert alert rules: %w", err) - } - for uid := range uids { - if err := service.provenanceStore.SetProvenance(ctx, &models.AlertRule{UID: uid}, orgID, provenance); err != nil { + // Delete first as this could prevent future unique constraint violations. + if len(delta.Delete) > 0 { + for _, del := range delta.Delete { + // check that provenance is not changed in an invalid way + storedProvenance, err := service.provenanceStore.GetProvenance(ctx, del, orgID) + if err != nil { + return err + } + if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate { + return fmt.Errorf("cannot update with provided provenance '%s', needs '%s'", provenance, storedProvenance) + } + } + if err := service.deleteRules(ctx, orgID, delta.Delete...); err != nil { return err } } - updates := make([]models.UpdateRule, 0, len(delta.Update)) - for _, update := range delta.Update { - // check that provenance is not changed in an invalid way - storedProvenance, err := service.provenanceStore.GetProvenance(ctx, update.New, orgID) - if err != nil { - return err + if len(delta.Update) > 0 { + updates := make([]models.UpdateRule, 0, len(delta.Update)) + for _, update := range delta.Update { + // check that provenance is not changed in an invalid way + storedProvenance, err := service.provenanceStore.GetProvenance(ctx, update.New, orgID) + if err != nil { + return err + } + if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate { + return fmt.Errorf("cannot update with provided provenance '%s', needs '%s'", provenance, storedProvenance) + } + updates = append(updates, models.UpdateRule{ + Existing: update.Existing, + New: *update.New, + }) } - if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate { - return fmt.Errorf("cannot update with provided provenance '%s', needs '%s'", provenance, storedProvenance) + if err = service.ruleStore.UpdateAlertRules(ctx, updates); err != nil { + return fmt.Errorf("failed to update alert rules: %w", err) } - updates = append(updates, models.UpdateRule{ - Existing: update.Existing, - New: *update.New, - }) - } - if err = service.ruleStore.UpdateAlertRules(ctx, updates); err != nil { - return fmt.Errorf("failed to update alert rules: %w", err) - } - for _, update := range delta.Update { - if err := service.provenanceStore.SetProvenance(ctx, update.New, orgID, provenance); err != nil { - return err + for _, update := range delta.Update { + if err := service.provenanceStore.SetProvenance(ctx, update.New, orgID, provenance); err != nil { + return err + } } } - for _, delete := range delta.Delete { - // check that provenance is not changed in an invalid way - storedProvenance, err := service.provenanceStore.GetProvenance(ctx, delete, orgID) + if len(delta.New) > 0 { + uids, err := service.ruleStore.InsertAlertRules(ctx, withoutNilAlertRules(delta.New)) if err != nil { - return err + return fmt.Errorf("failed to insert alert rules: %w", err) } - if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate { - return fmt.Errorf("cannot update with provided provenance '%s', needs '%s'", provenance, storedProvenance) + for uid := range uids { + if err := service.provenanceStore.SetProvenance(ctx, &models.AlertRule{UID: uid}, orgID, provenance); err != nil { + return err + } } } - if err := service.deleteRules(ctx, orgID, delta.Delete...); err != nil { - return err - } if err = service.checkLimitsTransactionCtx(ctx, orgID, userID); err != nil { return err diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index c3ac5fd3480..67f8d731512 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -160,6 +160,210 @@ func TestAlertRuleService(t *testing.T) { require.Equal(t, int64(2), readGroup.Rules[0].Version) }) + t.Run("updating a group to temporarily overlap rule names should not throw unique constraint", func(t *testing.T) { + var orgID int64 = 1 + group := models.AlertRuleGroup{ + Title: "overlap-test", + Interval: 60, + FolderUID: "my-namespace", + Rules: []models.AlertRule{ + dummyRule("overlap-test-rule-1", orgID), + dummyRule("overlap-test-rule-2", orgID), + }, + } + err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + require.NoError(t, err) + updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "overlap-test") + require.NoError(t, err) + + updatedGroup.Rules[0].Title = "overlap-test-rule-2" + updatedGroup.Rules[1].Title = "overlap-test-rule-3" + err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + require.NoError(t, err) + + readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "overlap-test") + require.NoError(t, err) + require.NotEmpty(t, readGroup.Rules) + require.Len(t, readGroup.Rules, 2) + require.Equal(t, "overlap-test-rule-2", readGroup.Rules[0].Title) + require.Equal(t, "overlap-test-rule-3", readGroup.Rules[1].Title) + require.Equal(t, int64(3), readGroup.Rules[0].Version) + require.Equal(t, int64(3), readGroup.Rules[1].Version) + }) + + t.Run("updating a group to swap the name of two rules should not throw unique constraint", func(t *testing.T) { + var orgID int64 = 1 + group := models.AlertRuleGroup{ + Title: "swap-test", + Interval: 60, + FolderUID: "my-namespace", + Rules: []models.AlertRule{ + dummyRule("swap-test-rule-1", orgID), + dummyRule("swap-test-rule-2", orgID), + }, + } + err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + require.NoError(t, err) + updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "swap-test") + require.NoError(t, err) + + updatedGroup.Rules[0].Title = "swap-test-rule-2" + updatedGroup.Rules[1].Title = "swap-test-rule-1" + err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + require.NoError(t, err) + + readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "swap-test") + require.NoError(t, err) + require.NotEmpty(t, readGroup.Rules) + require.Len(t, readGroup.Rules, 2) + require.Equal(t, "swap-test-rule-2", readGroup.Rules[0].Title) + require.Equal(t, "swap-test-rule-1", readGroup.Rules[1].Title) + require.Equal(t, int64(3), readGroup.Rules[0].Version) // Needed an extra update to break the update cycle. + require.Equal(t, int64(3), readGroup.Rules[1].Version) + }) + + t.Run("updating a group that has a rule name cycle should not throw unique constraint", func(t *testing.T) { + var orgID int64 = 1 + group := models.AlertRuleGroup{ + Title: "cycle-test", + Interval: 60, + FolderUID: "my-namespace", + Rules: []models.AlertRule{ + dummyRule("cycle-test-rule-1", orgID), + dummyRule("cycle-test-rule-2", orgID), + dummyRule("cycle-test-rule-3", orgID), + }, + } + err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + require.NoError(t, err) + updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "cycle-test") + require.NoError(t, err) + + updatedGroup.Rules[0].Title = "cycle-test-rule-2" + updatedGroup.Rules[1].Title = "cycle-test-rule-3" + updatedGroup.Rules[2].Title = "cycle-test-rule-1" + err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + require.NoError(t, err) + + readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "cycle-test") + require.NoError(t, err) + require.NotEmpty(t, readGroup.Rules) + require.Len(t, readGroup.Rules, 3) + require.Equal(t, "cycle-test-rule-2", readGroup.Rules[0].Title) + require.Equal(t, "cycle-test-rule-3", readGroup.Rules[1].Title) + require.Equal(t, "cycle-test-rule-1", readGroup.Rules[2].Title) + require.Equal(t, int64(3), readGroup.Rules[0].Version) // Needed an extra update to break the update cycle. + require.Equal(t, int64(3), readGroup.Rules[1].Version) + require.Equal(t, int64(3), readGroup.Rules[2].Version) + }) + + t.Run("updating a group that has multiple rule name cycles should not throw unique constraint", func(t *testing.T) { + var orgID int64 = 1 + group := models.AlertRuleGroup{ + Title: "multi-cycle-test", + Interval: 60, + FolderUID: "my-namespace", + Rules: []models.AlertRule{ + dummyRule("multi-cycle-test-rule-1", orgID), + dummyRule("multi-cycle-test-rule-2", orgID), + + dummyRule("multi-cycle-test-rule-3", orgID), + dummyRule("multi-cycle-test-rule-4", orgID), + dummyRule("multi-cycle-test-rule-5", orgID), + }, + } + err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + require.NoError(t, err) + updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "multi-cycle-test") + require.NoError(t, err) + + updatedGroup.Rules[0].Title = "multi-cycle-test-rule-2" + updatedGroup.Rules[1].Title = "multi-cycle-test-rule-1" + + updatedGroup.Rules[2].Title = "multi-cycle-test-rule-4" + updatedGroup.Rules[3].Title = "multi-cycle-test-rule-5" + updatedGroup.Rules[4].Title = "multi-cycle-test-rule-3" + + err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + require.NoError(t, err) + + readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "multi-cycle-test") + require.NoError(t, err) + require.NotEmpty(t, readGroup.Rules) + require.Len(t, readGroup.Rules, 5) + require.Equal(t, "multi-cycle-test-rule-2", readGroup.Rules[0].Title) + require.Equal(t, "multi-cycle-test-rule-1", readGroup.Rules[1].Title) + require.Equal(t, "multi-cycle-test-rule-4", readGroup.Rules[2].Title) + require.Equal(t, "multi-cycle-test-rule-5", readGroup.Rules[3].Title) + require.Equal(t, "multi-cycle-test-rule-3", readGroup.Rules[4].Title) + require.Equal(t, int64(3), readGroup.Rules[0].Version) // Needed an extra update to break the update cycle. + require.Equal(t, int64(3), readGroup.Rules[1].Version) + require.Equal(t, int64(3), readGroup.Rules[2].Version) // Needed an extra update to break the update cycle. + require.Equal(t, int64(3), readGroup.Rules[3].Version) + require.Equal(t, int64(3), readGroup.Rules[4].Version) + }) + + t.Run("updating a group to recreate a rule using the same name should not throw unique constraint", func(t *testing.T) { + var orgID int64 = 1 + group := models.AlertRuleGroup{ + Title: "recreate-test", + Interval: 60, + FolderUID: "my-namespace", + Rules: []models.AlertRule{ + dummyRule("recreate-test-rule-1", orgID), + }, + } + err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + require.NoError(t, err) + updatedGroup := models.AlertRuleGroup{ + Title: "recreate-test", + Interval: 60, + FolderUID: "my-namespace", + Rules: []models.AlertRule{ + dummyRule("recreate-test-rule-1", orgID), + }, + } + err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + require.NoError(t, err) + + readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "recreate-test") + require.NoError(t, err) + require.NotEmpty(t, readGroup.Rules) + require.Len(t, readGroup.Rules, 1) + require.Equal(t, "recreate-test-rule-1", readGroup.Rules[0].Title) + require.Equal(t, int64(1), readGroup.Rules[0].Version) + }) + + t.Run("updating a group to create a rule that temporarily overlaps an existing should not throw unique constraint", func(t *testing.T) { + var orgID int64 = 1 + group := models.AlertRuleGroup{ + Title: "create-overlap-test", + Interval: 60, + FolderUID: "my-namespace", + Rules: []models.AlertRule{ + dummyRule("create-overlap-test-rule-1", orgID), + }, + } + err := ruleService.ReplaceRuleGroup(context.Background(), orgID, group, 0, models.ProvenanceAPI) + require.NoError(t, err) + updatedGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "create-overlap-test") + require.NoError(t, err) + updatedGroup.Rules[0].Title = "create-overlap-test-rule-2" + updatedGroup.Rules = append(updatedGroup.Rules, dummyRule("create-overlap-test-rule-1", orgID)) + + err = ruleService.ReplaceRuleGroup(context.Background(), orgID, updatedGroup, 0, models.ProvenanceAPI) + require.NoError(t, err) + + readGroup, err := ruleService.GetRuleGroup(context.Background(), orgID, "my-namespace", "create-overlap-test") + require.NoError(t, err) + require.NotEmpty(t, readGroup.Rules) + require.Len(t, readGroup.Rules, 2) + require.Equal(t, "create-overlap-test-rule-2", readGroup.Rules[0].Title) + require.Equal(t, "create-overlap-test-rule-1", readGroup.Rules[1].Title) + require.Equal(t, int64(2), readGroup.Rules[0].Version) + require.Equal(t, int64(1), readGroup.Rules[1].Version) + }) + t.Run("updating a group by updating a rule should not remove dashboard and panel ids", func(t *testing.T) { dashboardUid := "huYnkl7H" panelId := int64(5678) diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 7e6f080aa42..78f468a1ca6 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "github.com/google/uuid" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" @@ -180,6 +182,11 @@ func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRu // UpdateAlertRules is a handler for updating alert rules. func (st DBstore) UpdateAlertRules(ctx context.Context, rules []ngmodels.UpdateRule) error { return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + err := st.preventIntermediateUniqueConstraintViolations(sess, rules) + if err != nil { + return fmt.Errorf("failed when preventing intermediate unique constraint violation: %w", err) + } + ruleVersions := make([]ngmodels.AlertRuleVersion, 0, len(rules)) for _, r := range rules { var parentVersion int64 @@ -231,6 +238,77 @@ func (st DBstore) UpdateAlertRules(ctx context.Context, rules []ngmodels.UpdateR }) } +// preventIntermediateUniqueConstraintViolations prevents unique constraint violations caused by an intermediate update. +// The uniqueness constraint for titles within an org+folder is enforced on every update within a transaction +// instead of on commit (deferred constraint). This means that there could be a set of updates that will throw +// a unique constraint violation in an intermediate step even though the final state is valid. +// For example, a chain of updates RuleA -> RuleB -> RuleC could fail if not executed in the correct order, or +// a swap of titles RuleA <-> RuleB cannot be executed in any order without violating the constraint. +func (st DBstore) preventIntermediateUniqueConstraintViolations(sess *db.Session, updates []ngmodels.UpdateRule) error { + // The exact solution to this is complex and requires determining directed paths and cycles in the update graph, + // adding in temporary updates to break cycles, and then executing the updates in reverse topological order. + // This is not implemented here. Instead, we choose a simpler solution that works in all cases but might perform + // more updates than necessary. This simpler solution makes a determination of whether an intermediate collision + // could occur and if so, adds a temporary title on all updated rules to break any cycles and remove the need for + // specific ordering. + + titleUpdates := make([]ngmodels.UpdateRule, 0) + for _, update := range updates { + if update.Existing.Title != update.New.Title { + titleUpdates = append(titleUpdates, update) + } + } + + // If there is no overlap then an intermediate unique constraint violation is not possible. If there is an overlap, + // then there is the possibility of intermediate unique constraint violation. + if !newTitlesOverlapExisting(titleUpdates) { + return nil + } + st.Logger.Debug("detected possible intermediate unique constraint violation, creating temporary title updates", "updates", len(titleUpdates)) + + for _, update := range titleUpdates { + r := update.Existing + u := uuid.New().String() + + // Some defensive programming in case the temporary title is somehow persisted it will still be recognizable. + uniqueTempTitle := r.Title + u + if len(uniqueTempTitle) > AlertRuleMaxTitleLength { + uniqueTempTitle = r.Title[:AlertRuleMaxTitleLength-len(u)] + uuid.New().String() + } + + if updated, err := sess.ID(r.ID).Cols("title").Update(&ngmodels.AlertRule{Title: uniqueTempTitle, Version: r.Version}); err != nil || updated == 0 { + if err != nil { + return fmt.Errorf("failed to set temporary rule title [%s] %s: %w", r.UID, r.Title, err) + } + return fmt.Errorf("%w: alert rule UID %s version %d", ErrOptimisticLock, r.UID, r.Version) + } + // Otherwise optimistic locking will conflict on the 2nd update. + r.Version++ + // For consistency. + r.Title = uniqueTempTitle + } + + return nil +} + +// newTitlesOverlapExisting returns true if any new titles overlap with existing titles. +// It does so in a case-insensitive manner as some supported databases perform case-insensitive comparisons. +func newTitlesOverlapExisting(rules []ngmodels.UpdateRule) bool { + existingTitles := make(map[string]struct{}, len(rules)) + for _, r := range rules { + existingTitles[strings.ToLower(r.Existing.Title)] = struct{}{} + } + + // Check if there is any overlap between lower case existing and new titles. + for _, r := range rules { + if _, ok := existingTitles[strings.ToLower(r.New.Title)]; ok { + return true + } + } + + return false +} + // CountInFolder is a handler for retrieving the number of alert rules of // specific organisation associated with a given namespace (parent folder). func (st DBstore) CountInFolder(ctx context.Context, orgID int64, folderUID string, u *user.SignedInUser) (int64, error) { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 87e77bf731e..493d267cce6 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -4,11 +4,13 @@ import ( "context" "errors" "fmt" + "strings" "testing" "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" @@ -37,6 +39,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { SQLStore: sqlStore, Cfg: cfg.UnifiedAlerting, FolderService: setupFolderService(t, sqlStore, cfg), + Logger: &logtest.Fake{}, } generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID()) @@ -79,6 +82,236 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { }) } +func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + cfg := setting.NewCfg() + cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second} + sqlStore := db.InitTestDB(t) + store := &DBstore{ + SQLStore: sqlStore, + Cfg: cfg.UnifiedAlerting, + FolderService: setupFolderService(t, sqlStore, cfg), + Logger: &logtest.Fake{}, + } + + idMutator := models.WithUniqueID() + createRuleInFolder := func(title string, orgID int64, namespaceUID string) *models.AlertRule { + generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), idMutator, models.WithNamespace(&folder.Folder{ + UID: namespaceUID, + Title: namespaceUID, + }), withOrgID(orgID), models.WithTitle(title)) + return createRule(t, store, generator) + } + + t.Run("should handle update chains without unique constraint violation", func(t *testing.T) { + rule1 := createRuleInFolder("chain-rule1", 1, "my-namespace") + rule2 := createRuleInFolder("chain-rule2", 1, "my-namespace") + + newRule1 := models.CopyRule(rule1) + newRule2 := models.CopyRule(rule2) + newRule1.Title = rule2.Title + newRule2.Title = util.GenerateShortUID() + + err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + Existing: rule1, + New: *newRule1, + }, { + Existing: rule2, + New: *newRule2, + }, + }) + require.NoError(t, err) + + dbrule1 := &models.AlertRule{} + dbrule2 := &models.AlertRule{} + err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + exist, err := sess.Table(models.AlertRule{}).ID(rule1.ID).Get(dbrule1) + if err != nil { + return err + } + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID)) + + exist, err = sess.Table(models.AlertRule{}).ID(rule2.ID).Get(dbrule2) + if err != nil { + return err + } + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID)) + return nil + }) + + require.NoError(t, err) + require.Equal(t, newRule1.Title, dbrule1.Title) + require.Equal(t, newRule2.Title, dbrule2.Title) + }) + + t.Run("should handle update chains with cycle without unique constraint violation", func(t *testing.T) { + rule1 := createRuleInFolder("cycle-rule1", 1, "my-namespace") + rule2 := createRuleInFolder("cycle-rule2", 1, "my-namespace") + rule3 := createRuleInFolder("cycle-rule3", 1, "my-namespace") + + newRule1 := models.CopyRule(rule1) + newRule2 := models.CopyRule(rule2) + newRule3 := models.CopyRule(rule3) + newRule1.Title = rule2.Title + newRule2.Title = rule3.Title + newRule3.Title = rule1.Title + + err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + Existing: rule1, + New: *newRule1, + }, { + Existing: rule2, + New: *newRule2, + }, { + Existing: rule3, + New: *newRule3, + }, + }) + require.NoError(t, err) + + dbrule1 := &models.AlertRule{} + dbrule2 := &models.AlertRule{} + dbrule3 := &models.AlertRule{} + err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + exist, err := sess.Table(models.AlertRule{}).ID(rule1.ID).Get(dbrule1) + if err != nil { + return err + } + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID)) + + exist, err = sess.Table(models.AlertRule{}).ID(rule2.ID).Get(dbrule2) + if err != nil { + return err + } + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID)) + + exist, err = sess.Table(models.AlertRule{}).ID(rule3.ID).Get(dbrule3) + if err != nil { + return err + } + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule3.ID)) + return nil + }) + + require.NoError(t, err) + require.Equal(t, newRule1.Title, dbrule1.Title) + require.Equal(t, newRule2.Title, dbrule2.Title) + require.Equal(t, newRule3.Title, dbrule3.Title) + }) + + t.Run("should handle case-insensitive intermediate collision without unique constraint violation", func(t *testing.T) { + rule1 := createRuleInFolder("case-cycle-rule1", 1, "my-namespace") + rule2 := createRuleInFolder("case-cycle-rule2", 1, "my-namespace") + + newRule1 := models.CopyRule(rule1) + newRule2 := models.CopyRule(rule2) + newRule1.Title = strings.ToUpper(rule2.Title) + newRule2.Title = strings.ToUpper(rule1.Title) + + err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + Existing: rule1, + New: *newRule1, + }, { + Existing: rule2, + New: *newRule2, + }, + }) + require.NoError(t, err) + + dbrule1 := &models.AlertRule{} + dbrule2 := &models.AlertRule{} + err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + exist, err := sess.Table(models.AlertRule{}).ID(rule1.ID).Get(dbrule1) + if err != nil { + return err + } + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID)) + + exist, err = sess.Table(models.AlertRule{}).ID(rule2.ID).Get(dbrule2) + if err != nil { + return err + } + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID)) + return nil + }) + + require.NoError(t, err) + require.Equal(t, newRule1.Title, dbrule1.Title) + require.Equal(t, newRule2.Title, dbrule2.Title) + }) + + t.Run("should handle update multiple chains in different folders without unique constraint violation", func(t *testing.T) { + rule1 := createRuleInFolder("multi-cycle-rule1", 1, "my-namespace") + rule2 := createRuleInFolder("multi-cycle-rule2", 1, "my-namespace") + rule3 := createRuleInFolder("multi-cycle-rule1", 1, "my-namespace2") + rule4 := createRuleInFolder("multi-cycle-rule2", 1, "my-namespace2") + + newRule1 := models.CopyRule(rule1) + newRule2 := models.CopyRule(rule2) + newRule3 := models.CopyRule(rule3) + newRule4 := models.CopyRule(rule4) + newRule1.Title = rule2.Title + newRule2.Title = rule1.Title + newRule3.Title = rule4.Title + newRule4.Title = rule3.Title + + err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + Existing: rule1, + New: *newRule1, + }, { + Existing: rule2, + New: *newRule2, + }, { + Existing: rule3, + New: *newRule3, + }, { + Existing: rule4, + New: *newRule4, + }, + }) + require.NoError(t, err) + + dbrule1 := &models.AlertRule{} + dbrule2 := &models.AlertRule{} + dbrule3 := &models.AlertRule{} + dbrule4 := &models.AlertRule{} + err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + exist, err := sess.Table(models.AlertRule{}).ID(rule1.ID).Get(dbrule1) + if err != nil { + return err + } + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID)) + + exist, err = sess.Table(models.AlertRule{}).ID(rule2.ID).Get(dbrule2) + if err != nil { + return err + } + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID)) + + exist, err = sess.Table(models.AlertRule{}).ID(rule3.ID).Get(dbrule3) + if err != nil { + return err + } + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule3.ID)) + + exist, err = sess.Table(models.AlertRule{}).ID(rule4.ID).Get(dbrule4) + if err != nil { + return err + } + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule4.ID)) + return nil + }) + + require.NoError(t, err) + require.Equal(t, newRule1.Title, dbrule1.Title) + require.Equal(t, newRule2.Title, dbrule2.Title) + require.Equal(t, newRule3.Title, dbrule3.Title) + require.Equal(t, newRule4.Title, dbrule4.Title) + }) +} + func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -99,6 +332,8 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID(), models.WithUniqueOrgID()) rule1 := createRule(t, store, generator) rule2 := createRule(t, store, generator) + createFolder(t, store, rule1.NamespaceUID, rule1.Title, rule1.OrgID) + createFolder(t, store, rule2.NamespaceUID, rule2.Title, rule2.OrgID) tc := []struct { name string @@ -251,7 +486,6 @@ func createRule(t *testing.T, store *DBstore, generate func() *models.AlertRule) generate = models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID()) } rule := generate() - createFolder(t, store, rule.NamespaceUID, rule.Title, rule.OrgID) err := store.SQLStore.WithDbSession(context.Background(), func(sess *db.Session) error { _, err := sess.Table(models.AlertRule{}).InsertOne(rule) if err != nil { diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index d3884a8810e..e774a08068d 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -358,9 +358,10 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) { require.Len(t, createdRuleGroup.Rules, 2) t.Run("trying to create alert with same title under same folder should fail", func(t *testing.T) { - rules := newTestingRuleConfig(t) + rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup) + rulesWithUID.Rules = append(rulesWithUID.Rules, rules.Rules[0]) // Create new copy of first rule. - status, body := apiClient.PostRulesGroup(t, "folder1", &rules) + status, body := apiClient.PostRulesGroup(t, "folder1", &rulesWithUID) assert.Equal(t, http.StatusInternalServerError, status) var res map[string]interface{} @@ -369,12 +370,10 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) { }) t.Run("trying to update an alert to the title of an existing alert in the same folder should fail", func(t *testing.T) { - rules := newTestingRuleConfig(t) - rules.Rules[0].GrafanaManagedAlert.UID = createdRuleGroup.Rules[0].GrafanaManagedAlert.UID - rules.Rules[1].GrafanaManagedAlert.UID = createdRuleGroup.Rules[1].GrafanaManagedAlert.UID - rules.Rules[1].GrafanaManagedAlert.Title = "AlwaysFiring" + rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup) + rulesWithUID.Rules[1].GrafanaManagedAlert.Title = "AlwaysFiring" - status, body := apiClient.PostRulesGroup(t, "folder1", &rules) + status, body := apiClient.PostRulesGroup(t, "folder1", &rulesWithUID) assert.Equal(t, http.StatusInternalServerError, status) var res map[string]interface{} @@ -388,6 +387,28 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) { assert.Equal(t, http.StatusAccepted, status) require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) }) + + t.Run("trying to swap titles of existing alerts in the same folder should work", func(t *testing.T) { + rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup) + title0 := rulesWithUID.Rules[0].GrafanaManagedAlert.Title + title1 := rulesWithUID.Rules[1].GrafanaManagedAlert.Title + rulesWithUID.Rules[0].GrafanaManagedAlert.Title = title1 + rulesWithUID.Rules[1].GrafanaManagedAlert.Title = title0 + + status, body := apiClient.PostRulesGroup(t, "folder1", &rulesWithUID) + assert.Equal(t, http.StatusAccepted, status) + require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) + }) + + t.Run("trying to update titles of existing alerts in a chain in the same folder should work", func(t *testing.T) { + rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup) + rulesWithUID.Rules[0].GrafanaManagedAlert.Title = rulesWithUID.Rules[1].GrafanaManagedAlert.Title + rulesWithUID.Rules[1].GrafanaManagedAlert.Title = "something new" + + status, body := apiClient.PostRulesGroup(t, "folder1", &rulesWithUID) + assert.Equal(t, http.StatusAccepted, status) + require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) + }) } func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) { From ba3994d33877049909819add70cc3d1bf1033cc7 Mon Sep 17 00:00:00 2001 From: Matthew Jacobson Date: Thu, 8 Jun 2023 18:59:54 -0400 Subject: [PATCH 09/51] Alerting: Repurpose rule testing endpoint to return potential alerts (#69755) * Alerting: Repurpose rule testing endpoint to return potential alerts This feature replaces the existing no-longer in-use grafana ruler testing API endpoint /api/v1/rule/test/grafana. The new endpoint returns a list of potential alerts created by the given alert rule, including built-in + interpolated labels and annotations. The key priority of this endpoint is that it is intended to be as true as possible to what would be generated by the ruler except that the resulting alerts are not filtered to only Resolved / Firing and ready to be sent. This means that the endpoint will, among other things: - Attach static annotations and labels from the rule configuration to the alert instances. - Attach dynamic annotations from the datasource to the alert instances. - Attach built-in labels and annotations created by the Grafana Ruler (such as alertname and grafana_folder) to the alert instances. - Interpolate templated annotations / labels and accept allowed template functions. --- pkg/services/ngalert/api/api.go | 1 + pkg/services/ngalert/api/api_testing.go | 82 ++-- pkg/services/ngalert/api/api_testing_test.go | 141 +++++- .../ngalert/api/generated_base_api_testing.go | 2 +- pkg/services/ngalert/api/testing_api.go | 2 +- pkg/services/ngalert/api/tooling/api.json | 175 +++++++- .../definitions/ruler_state_history.go | 2 + .../api/tooling/definitions/testing.go | 39 +- pkg/services/ngalert/api/tooling/post.json | 196 ++++++++- pkg/services/ngalert/api/tooling/spec.json | 196 ++++++++- pkg/services/ngalert/notifier/templates.go | 2 +- pkg/services/ngalert/schedule/schedule.go | 27 +- .../ngalert/schedule/schedule_unit_test.go | 2 +- .../ngalert/{schedule => state}/compat.go | 21 +- .../{schedule => state}/compat_test.go | 45 +- pkg/services/ngalert/state/state.go | 16 + .../api/alerting/api_alertmanager_test.go | 231 ---------- pkg/tests/api/alerting/api_testing_test.go | 408 ++++++++++++++++++ pkg/tests/api/alerting/testing.go | 19 + 19 files changed, 1246 insertions(+), 361 deletions(-) rename pkg/services/ngalert/{schedule => state}/compat.go (87%) rename pkg/services/ngalert/{schedule => state}/compat_test.go (87%) create mode 100644 pkg/tests/api/alerting/api_testing_test.go diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 08fedec5108..2f7b106b591 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -136,6 +136,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { cfg: &api.Cfg.UnifiedAlerting, backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory), featureManager: api.FeatureManager, + appUrl: api.AppUrl, }), m) api.RegisterConfigurationApiEndpoints(NewConfiguration( &ConfigSrv{ diff --git a/pkg/services/ngalert/api/api_testing.go b/pkg/services/ngalert/api/api_testing.go index d9ad80b0681..9acbc01d07b 100644 --- a/pkg/services/ngalert/api/api_testing.go +++ b/pkg/services/ngalert/api/api_testing.go @@ -8,7 +8,10 @@ import ( "strconv" "time" + "github.com/benbjohnson/clock" + "github.com/grafana/alerting/models" "github.com/grafana/grafana-plugin-sdk-go/data" + amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" @@ -16,10 +19,12 @@ import ( contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/backtesting" "github.com/grafana/grafana/pkg/services/ngalert/eval" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -33,46 +38,73 @@ type TestingApiSrv struct { cfg *setting.UnifiedAlertingSettings backtesting *backtesting.Engine featureManager featuremgmt.FeatureToggles + appUrl *url.URL } -func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload) response.Response { - if body.Type() != apimodels.GrafanaBackend || body.GrafanaManagedCondition == nil { - return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.GrafanaBackend, body.Type().String())) +// RouteTestGrafanaRuleConfig returns a list of potential alerts for a given rule configuration. This is intended to be +// as true as possible to what would be generated by the ruler except that the resulting alerts are not filtered to +// only Resolved / Firing and ready to send. +func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, body apimodels.PostableExtendedRuleNodeExtended) response.Response { + rule, err := validateRuleNode( + &body.Rule, + body.RuleGroup, + srv.cfg.BaseInterval, + c.OrgID, + &folder.Folder{ + OrgID: c.OrgID, + UID: body.NamespaceUID, + Title: body.NamespaceTitle, + }, + func(condition ngmodels.Condition) error { + return srv.evaluator.Validate(eval.NewContext(c.Req.Context(), c.SignedInUser), condition) + }, + srv.cfg, + ) + if err != nil { + return ErrResp(http.StatusBadRequest, err, "") } - queries := AlertQueriesFromApiAlertQueries(body.GrafanaManagedCondition.Data) - - if !authorizeDatasourceAccessForRule(&ngmodels.AlertRule{Data: queries}, func(evaluator accesscontrol.Evaluator) bool { + if !authorizeDatasourceAccessForRule(rule, func(evaluator accesscontrol.Evaluator) bool { return accesscontrol.HasAccess(srv.accessControl, c)(evaluator) }) { return errorToResponse(fmt.Errorf("%w to query one or many data sources used by the rule", ErrAuthorization)) } - evalCond := ngmodels.Condition{ - Condition: body.GrafanaManagedCondition.Condition, - Data: queries, - } - ctx := eval.NewContext(c.Req.Context(), c.SignedInUser) - - conditionEval, err := srv.evaluator.Create(ctx, evalCond) + evaluator, err := srv.evaluator.Create(eval.NewContext(c.Req.Context(), c.SignedInUser), rule.GetEvalCondition()) if err != nil { - return ErrResp(http.StatusBadRequest, err, "invalid condition") + return ErrResp(http.StatusBadRequest, err, "Failed to build evaluator for queries and expressions") } - now := body.GrafanaManagedCondition.Now - if now.IsZero() { - now = timeNow() - } - - evalResults, err := conditionEval.Evaluate(c.Req.Context(), now) + now := time.Now() + results, err := evaluator.Evaluate(c.Req.Context(), now) if err != nil { - return ErrResp(500, err, "Failed to evaluate the rule") + return ErrResp(http.StatusInternalServerError, err, "Failed to evaluate queries") } - frame := evalResults.AsDataFrame() - return response.JSONStreaming(http.StatusOK, util.DynMap{ - "instances": []*data.Frame{&frame}, - }) + cfg := state.ManagerCfg{ + Metrics: nil, + ExternalURL: srv.appUrl, + InstanceStore: nil, + Images: &backtesting.NoopImageService{}, + Clock: clock.New(), + Historian: nil, + } + manager := state.NewManager(cfg) + includeFolder := !srv.cfg.ReservedLabels.IsReservedLabelDisabled(models.FolderTitleLabel) + transitions := manager.ProcessEvalResults( + c.Req.Context(), + now, + rule, + results, + state.GetRuleExtraLabels(rule, body.NamespaceTitle, includeFolder), + ) + + alerts := make([]*amv2.PostableAlert, 0, len(transitions)) + for _, alertState := range transitions { + alerts = append(alerts, state.StateToPostableAlert(alertState.State, srv.appUrl)) + } + + return response.JSON(http.StatusOK, alerts) } func (srv TestingApiSrv) RouteTestRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload, datasourceUID string) response.Response { diff --git a/pkg/services/ngalert/api/api_testing_test.go b/pkg/services/ngalert/api/api_testing_test.go index 5fd98200e79..dde667a7690 100644 --- a/pkg/services/ngalert/api/api_testing_test.go +++ b/pkg/services/ngalert/api/api_testing_test.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "net/http" "testing" "time" @@ -22,6 +23,107 @@ import ( "github.com/grafana/grafana/pkg/web" ) +func Test(t *testing.T) { + text := `{ + "rule": { +"grafana_alert" : { + "condition": "C", + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "queryType": "", + "datasourceUid": "PD8C576611E62080A", + "model": { + "refId": "A", + "hide": false, + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "scenarioId": "random_walk", + "seriesCount": 5, + "labels": "series=series-$seriesIndex" + } + }, + { + "refId": "B", + "datasourceUid": "__expr__", + "queryType": "", + "model": { + "refId": "B", + "hide": false, + "type": "reduce", + "datasource": { + "uid": "__expr__", + "type": "__expr__" + }, + "reducer": "last", + "expression": "A" + }, + "relativeTimeRange": { + "from": 600, + "to": 0 + } + }, + { + "refId": "C", + "datasourceUid": "__expr__", + "queryType": "", + "model": { + "refId": "C", + "hide": false, + "type": "threshold", + "datasource": { + "uid": "__expr__", + "type": "__expr__" + }, + "conditions": [ + { + "type": "query", + "evaluator": { + "params": [ + 0 + ], + "type": "gt" + } + } + ], + "expression": "B" + }, + "relativeTimeRange": { + "from": 600, + "to": 0 + } + } + ], + "no_data_state": "Alerting", +"title": "string" +}, + "for": "0s", + "labels": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "annotations": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + } + }, + "folderUid": "test-uid", + "folderTitle": "test-folder" +}` + var conf definitions.PostableExtendedRuleNodeExtended + require.NoError(t, json.Unmarshal([]byte(text), &conf)) + + require.Equal(t, "test-folder", conf.NamespaceTitle) +} + func TestRouteTestGrafanaRuleConfig(t *testing.T) { t.Run("when fine-grained access is enabled", func(t *testing.T) { rc := &contextmodel.ReqContext{ @@ -41,15 +143,14 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) { {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)}, }) - srv := createTestingApiSrv(nil, ac, nil) + srv := createTestingApiSrv(t, nil, ac, eval_mocks.NewEvaluatorFactory(&eval_mocks.ConditionEvaluatorMock{})) - response := srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{ - Expr: "", - GrafanaManagedCondition: &definitions.EvalAlertConditionCommand{ - Condition: data1.RefID, - Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}), - Now: time.Time{}, - }, + rule := validRule() + rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}) + response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{ + Rule: rule, + NamespaceUID: "test-folder", + NamespaceTitle: "test-folder", }) require.Equal(t, http.StatusUnauthorized, response.Status()) @@ -59,8 +160,6 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) { data1 := models.GenerateAlertQuery() data2 := models.GenerateAlertQuery() - currentTime := time.Now() - ac := acMock.New().WithPermissions([]accesscontrol.Permission{ {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)}, {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data2.DatasourceUID)}, @@ -77,20 +176,19 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) { evalFactory := eval_mocks.NewEvaluatorFactory(evaluator) - srv := createTestingApiSrv(ds, ac, evalFactory) + srv := createTestingApiSrv(t, ds, ac, evalFactory) - response := srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{ - Expr: "", - GrafanaManagedCondition: &definitions.EvalAlertConditionCommand{ - Condition: data1.RefID, - Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}), - Now: currentTime, - }, + rule := validRule() + rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}) + response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{ + Rule: rule, + NamespaceUID: "test-folder", + NamespaceTitle: "test-folder", }) require.Equal(t, http.StatusOK, response.Status()) - evaluator.AssertCalled(t, "Evaluate", mock.Anything, currentTime) + evaluator.AssertCalled(t, "Evaluate", mock.Anything, mock.Anything) }) }) } @@ -153,7 +251,7 @@ func TestRouteEvalQueries(t *testing.T) { } evaluator.EXPECT().EvaluateRaw(mock.Anything, mock.Anything).Return(result, nil) - srv := createTestingApiSrv(ds, ac, eval_mocks.NewEvaluatorFactory(evaluator)) + srv := createTestingApiSrv(t, ds, ac, eval_mocks.NewEvaluatorFactory(evaluator)) response := srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{ Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}), @@ -167,7 +265,7 @@ func TestRouteEvalQueries(t *testing.T) { }) } -func createTestingApiSrv(ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator eval.EvaluatorFactory) *TestingApiSrv { +func createTestingApiSrv(t *testing.T, ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator eval.EvaluatorFactory) *TestingApiSrv { if ac == nil { ac = acMock.New().WithDisabled() } @@ -176,5 +274,6 @@ func createTestingApiSrv(ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator DatasourceCache: ds, accessControl: ac, evaluator: evaluator, + cfg: config(t), } } diff --git a/pkg/services/ngalert/api/generated_base_api_testing.go b/pkg/services/ngalert/api/generated_base_api_testing.go index 342d0460b5f..1c6d5427501 100644 --- a/pkg/services/ngalert/api/generated_base_api_testing.go +++ b/pkg/services/ngalert/api/generated_base_api_testing.go @@ -53,7 +53,7 @@ func (f *TestingApiHandler) RouteTestRuleConfig(ctx *contextmodel.ReqContext) re } func (f *TestingApiHandler) RouteTestRuleGrafanaConfig(ctx *contextmodel.ReqContext) response.Response { // Parse Request Body - conf := apimodels.TestRulePayload{} + conf := apimodels.PostableExtendedRuleNodeExtended{} if err := web.Bind(ctx.Req, &conf); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } diff --git a/pkg/services/ngalert/api/testing_api.go b/pkg/services/ngalert/api/testing_api.go index 23da0884de3..70b54e12f0c 100644 --- a/pkg/services/ngalert/api/testing_api.go +++ b/pkg/services/ngalert/api/testing_api.go @@ -21,7 +21,7 @@ func (f *TestingApiHandler) handleRouteTestRuleConfig(c *contextmodel.ReqContext return f.svc.RouteTestRuleConfig(c, body, dsUID) } -func (f *TestingApiHandler) handleRouteTestRuleGrafanaConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload) response.Response { +func (f *TestingApiHandler) handleRouteTestRuleGrafanaConfig(c *contextmodel.ReqContext, body apimodels.PostableExtendedRuleNodeExtended) response.Response { return f.svc.RouteTestGrafanaRuleConfig(c, body) } diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index bdb1ac22de1..0e7557841cd 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -543,6 +543,12 @@ }, "type": "array" }, + "CounterResetHint": { + "description": "or alternatively that we are dealing with a gauge histogram, where counter resets do not apply.", + "format": "uint8", + "title": "CounterResetHint contains the known information about a counter reset,", + "type": "integer" + }, "DataLink": { "description": "DataLink define what", "properties": { @@ -889,7 +895,7 @@ "type": "string" }, "displayNameFromDS": { - "description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.", + "description": "DisplayNameFromDS overrides Grafana default naming strategy.", "type": "string" }, "filterable": { @@ -952,6 +958,56 @@ }, "type": "object" }, + "FloatHistogram": { + "description": "A FloatHistogram is needed by PromQL to handle operations that might result\nin fractional counts. Since the counts in a histogram are unlikely to be too\nlarge to be represented precisely by a float64, a FloatHistogram can also be\nused to represent a histogram with integer counts and thus serves as a more\ngeneralized representation.", + "properties": { + "Count": { + "description": "Total number of observations. Must be zero or positive.", + "format": "double", + "type": "number" + }, + "CounterResetHint": { + "$ref": "#/definitions/CounterResetHint" + }, + "PositiveBuckets": { + "description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.", + "items": { + "format": "double", + "type": "number" + }, + "type": "array" + }, + "PositiveSpans": { + "description": "Spans for positive and negative buckets (see Span below).", + "items": { + "$ref": "#/definitions/Span" + }, + "type": "array" + }, + "Schema": { + "description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).", + "format": "int32", + "type": "integer" + }, + "Sum": { + "description": "Sum of observations. This is also used as the stale marker.", + "format": "double", + "type": "number" + }, + "ZeroCount": { + "description": "Observations falling into the zero bucket. Must be zero or positive.", + "format": "double", + "type": "number" + }, + "ZeroThreshold": { + "description": "Width of the zero bucket.", + "format": "double", + "type": "number" + } + }, + "title": "FloatHistogram is similar to Histogram but uses float64 for all\ncounts. Additionally, bucket counts are absolute and not deltas.", + "type": "object" + }, "Frame": { "description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.", "properties": { @@ -1553,12 +1609,20 @@ "description": "FollowRedirects specifies whether the client should follow HTTP 3xx redirects.\nThe omitempty flag is not set, because it would be hidden from the\nmarshalled configuration when set to false.", "type": "boolean" }, + "no_proxy": { + "description": "NoProxy contains addresses that should not use a proxy.", + "type": "string" + }, "oauth2": { "$ref": "#/definitions/OAuth2" }, "proxy_connect_header": { "$ref": "#/definitions/Header" }, + "proxy_from_environment": { + "description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.", + "type": "boolean" + }, "proxy_url": { "$ref": "#/definitions/URL" }, @@ -1865,6 +1929,17 @@ }, "type": "object" }, + "no_proxy": { + "description": "NoProxy contains addresses that should not use a proxy.", + "type": "string" + }, + "proxy_connect_header": { + "$ref": "#/definitions/Header" + }, + "proxy_from_environment": { + "description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.", + "type": "boolean" + }, "proxy_url": { "$ref": "#/definitions/URL" }, @@ -2064,7 +2139,11 @@ "type": "object" }, "Point": { + "description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.", "properties": { + "H": { + "$ref": "#/definitions/FloatHistogram" + }, "T": { "format": "int64", "type": "integer" @@ -2232,6 +2311,29 @@ }, "type": "object" }, + "PostableExtendedRuleNodeExtended": { + "properties": { + "folderTitle": { + "example": "project_x", + "type": "string" + }, + "folderUid": { + "example": "okrd3I0Vz", + "type": "string" + }, + "rule": { + "$ref": "#/definitions/PostableExtendedRuleNode" + }, + "ruleGroup": { + "example": "eval_group_1", + "type": "string" + } + }, + "required": [ + "rule" + ], + "type": "object" + }, "PostableGrafanaReceiver": { "properties": { "disableResolveMessage": { @@ -2508,8 +2610,30 @@ }, "type": "array" }, + "ProxyConfig": { + "properties": { + "no_proxy": { + "description": "NoProxy contains addresses that should not use a proxy.", + "type": "string" + }, + "proxy_connect_header": { + "$ref": "#/definitions/Header" + }, + "proxy_from_environment": { + "description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.", + "type": "boolean" + }, + "proxy_url": { + "$ref": "#/definitions/URL" + } + }, + "type": "object" + }, "PushoverConfig": { "properties": { + "device": { + "type": "string" + }, "expire": { "type": "string" }, @@ -2584,7 +2708,7 @@ "type": "string" }, "displayNameFromDS": { - "description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.", + "description": "DisplayNameFromDS overrides Grafana default naming strategy.", "type": "string" }, "filterable": { @@ -3007,6 +3131,9 @@ }, "Sample": { "properties": { + "H": { + "$ref": "#/definitions/FloatHistogram" + }, "Metric": { "$ref": "#/definitions/Labels" }, @@ -3201,6 +3328,22 @@ "SmtpNotEnabled": { "$ref": "#/definitions/ResponseDetails" }, + "Span": { + "properties": { + "Length": { + "description": "Length of the span.", + "format": "uint32", + "type": "integer" + }, + "Offset": { + "description": "Gap to previous span (always positive), or starting index for the 1st\nspan (which can be negative).", + "format": "int32", + "type": "integer" + } + }, + "title": "A Span defines a continuous sequence of buckets.", + "type": "object" + }, "Status": { "format": "int64", "type": "integer" @@ -3273,6 +3416,9 @@ }, "token": { "$ref": "#/definitions/Secret" + }, + "token_file": { + "type": "string" } }, "title": "TelegramConfig configures notifications via Telegram.", @@ -3684,7 +3830,10 @@ "type": "boolean" }, "url": { - "$ref": "#/definitions/URL" + "$ref": "#/definitions/SecretURL" + }, + "url_file": { + "type": "string" } }, "title": "WebhookConfig configures notifications via a generic webhook.", @@ -3875,7 +4024,6 @@ "type": "object" }, "gettableAlert": { - "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -3931,13 +4079,13 @@ "type": "object" }, "gettableAlerts": { - "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert" }, "type": "array" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -4136,6 +4284,7 @@ "type": "array" }, "postableSilence": { + "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -4173,7 +4322,6 @@ "type": "object" }, "receiver": { - "description": "Receiver receiver", "properties": { "active": { "description": "active", @@ -5116,6 +5264,21 @@ "type": "array" } }, + "StateHistory": { + "description": "", + "schema": { + "$ref": "#/definitions/Frame" + } + }, + "TestGrafanaRuleResponse": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/postableAlert" + }, + "type": "array" + } + }, "receiversResponse": { "description": "", "schema": { diff --git a/pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go b/pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go index 4c31b416cf3..9d365659cc8 100644 --- a/pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go +++ b/pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go @@ -12,6 +12,8 @@ import "github.com/grafana/grafana-plugin-sdk-go/data" // Responses: // 200: StateHistory +// swagger:response StateHistory type StateHistory struct { + // in:body Results *data.Frame `json:"results"` } diff --git a/pkg/services/ngalert/api/tooling/definitions/testing.go b/pkg/services/ngalert/api/tooling/definitions/testing.go index 9824b29eda5..e5c3265dbab 100644 --- a/pkg/services/ngalert/api/tooling/definitions/testing.go +++ b/pkg/services/ngalert/api/tooling/definitions/testing.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/config" "github.com/prometheus/common/model" "github.com/prometheus/prometheus/promql" @@ -23,7 +24,9 @@ import ( // - application/json // // Responses: -// 200: TestRuleResponse +// 200: TestGrafanaRuleResponse +// 400: ValidationError +// 404: NotFound // swagger:route Post /api/v1/rule/test/{DatasourceUID} testing RouteTestRuleConfig // @@ -71,7 +74,7 @@ type TestReceiverRequest struct { Body ExtendedReceiver } -// swagger:parameters RouteTestRuleConfig RouteTestRuleGrafanaConfig +// swagger:parameters RouteTestRuleConfig type TestRuleRequest struct { // in:body Body TestRulePayload @@ -85,6 +88,38 @@ type TestRulePayload struct { GrafanaManagedCondition *EvalAlertConditionCommand `json:"grafana_condition,omitempty"` } +// swagger:response TestGrafanaRuleResponse +type TestGrafanaRuleResponse struct { + // in:body + Body []amv2.PostableAlert +} + +// swagger:parameters RouteTestRuleGrafanaConfig +type TestGrafanaRuleRequest struct { + // in:body + Body PostableExtendedRuleNodeExtended +} + +// swagger:model +type PostableExtendedRuleNodeExtended struct { + // required: true + Rule PostableExtendedRuleNode `json:"rule"` + // example: okrd3I0Vz + NamespaceUID string `json:"folderUid"` + // example: project_x + NamespaceTitle string `json:"folderTitle"` + // example: eval_group_1 + RuleGroup string `json:"ruleGroup"` +} + +func (n *PostableExtendedRuleNodeExtended) UnmarshalJSON(b []byte) error { + type plain PostableExtendedRuleNodeExtended + if err := json.Unmarshal(b, (*plain)(n)); err != nil { + return err + } + return nil +} + // swagger:parameters RouteEvalQueries type EvalQueriesRequest struct { // in:body diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index ef1087733a9..478d010a74c 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -543,6 +543,12 @@ }, "type": "array" }, + "CounterResetHint": { + "description": "or alternatively that we are dealing with a gauge histogram, where counter resets do not apply.", + "format": "uint8", + "title": "CounterResetHint contains the known information about a counter reset,", + "type": "integer" + }, "DataLink": { "description": "DataLink define what", "properties": { @@ -889,7 +895,7 @@ "type": "string" }, "displayNameFromDS": { - "description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.", + "description": "DisplayNameFromDS overrides Grafana default naming strategy.", "type": "string" }, "filterable": { @@ -952,6 +958,56 @@ }, "type": "object" }, + "FloatHistogram": { + "description": "A FloatHistogram is needed by PromQL to handle operations that might result\nin fractional counts. Since the counts in a histogram are unlikely to be too\nlarge to be represented precisely by a float64, a FloatHistogram can also be\nused to represent a histogram with integer counts and thus serves as a more\ngeneralized representation.", + "properties": { + "Count": { + "description": "Total number of observations. Must be zero or positive.", + "format": "double", + "type": "number" + }, + "CounterResetHint": { + "$ref": "#/definitions/CounterResetHint" + }, + "PositiveBuckets": { + "description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.", + "items": { + "format": "double", + "type": "number" + }, + "type": "array" + }, + "PositiveSpans": { + "description": "Spans for positive and negative buckets (see Span below).", + "items": { + "$ref": "#/definitions/Span" + }, + "type": "array" + }, + "Schema": { + "description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).", + "format": "int32", + "type": "integer" + }, + "Sum": { + "description": "Sum of observations. This is also used as the stale marker.", + "format": "double", + "type": "number" + }, + "ZeroCount": { + "description": "Observations falling into the zero bucket. Must be zero or positive.", + "format": "double", + "type": "number" + }, + "ZeroThreshold": { + "description": "Width of the zero bucket.", + "format": "double", + "type": "number" + } + }, + "title": "FloatHistogram is similar to Histogram but uses float64 for all\ncounts. Additionally, bucket counts are absolute and not deltas.", + "type": "object" + }, "Frame": { "description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.", "properties": { @@ -1553,12 +1609,20 @@ "description": "FollowRedirects specifies whether the client should follow HTTP 3xx redirects.\nThe omitempty flag is not set, because it would be hidden from the\nmarshalled configuration when set to false.", "type": "boolean" }, + "no_proxy": { + "description": "NoProxy contains addresses that should not use a proxy.", + "type": "string" + }, "oauth2": { "$ref": "#/definitions/OAuth2" }, "proxy_connect_header": { "$ref": "#/definitions/Header" }, + "proxy_from_environment": { + "description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.", + "type": "boolean" + }, "proxy_url": { "$ref": "#/definitions/URL" }, @@ -1865,6 +1929,17 @@ }, "type": "object" }, + "no_proxy": { + "description": "NoProxy contains addresses that should not use a proxy.", + "type": "string" + }, + "proxy_connect_header": { + "$ref": "#/definitions/Header" + }, + "proxy_from_environment": { + "description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.", + "type": "boolean" + }, "proxy_url": { "$ref": "#/definitions/URL" }, @@ -2064,7 +2139,11 @@ "type": "object" }, "Point": { + "description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.", "properties": { + "H": { + "$ref": "#/definitions/FloatHistogram" + }, "T": { "format": "int64", "type": "integer" @@ -2232,6 +2311,29 @@ }, "type": "object" }, + "PostableExtendedRuleNodeExtended": { + "properties": { + "folderTitle": { + "example": "project_x", + "type": "string" + }, + "folderUid": { + "example": "okrd3I0Vz", + "type": "string" + }, + "rule": { + "$ref": "#/definitions/PostableExtendedRuleNode" + }, + "ruleGroup": { + "example": "eval_group_1", + "type": "string" + } + }, + "required": [ + "rule" + ], + "type": "object" + }, "PostableGrafanaReceiver": { "properties": { "disableResolveMessage": { @@ -2508,8 +2610,30 @@ }, "type": "array" }, + "ProxyConfig": { + "properties": { + "no_proxy": { + "description": "NoProxy contains addresses that should not use a proxy.", + "type": "string" + }, + "proxy_connect_header": { + "$ref": "#/definitions/Header" + }, + "proxy_from_environment": { + "description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.", + "type": "boolean" + }, + "proxy_url": { + "$ref": "#/definitions/URL" + } + }, + "type": "object" + }, "PushoverConfig": { "properties": { + "device": { + "type": "string" + }, "expire": { "type": "string" }, @@ -2584,7 +2708,7 @@ "type": "string" }, "displayNameFromDS": { - "description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.", + "description": "DisplayNameFromDS overrides Grafana default naming strategy.", "type": "string" }, "filterable": { @@ -3007,6 +3131,9 @@ }, "Sample": { "properties": { + "H": { + "$ref": "#/definitions/FloatHistogram" + }, "Metric": { "$ref": "#/definitions/Labels" }, @@ -3201,6 +3328,22 @@ "SmtpNotEnabled": { "$ref": "#/definitions/ResponseDetails" }, + "Span": { + "properties": { + "Length": { + "description": "Length of the span.", + "format": "uint32", + "type": "integer" + }, + "Offset": { + "description": "Gap to previous span (always positive), or starting index for the 1st\nspan (which can be negative).", + "format": "int32", + "type": "integer" + } + }, + "title": "A Span defines a continuous sequence of buckets.", + "type": "object" + }, "Status": { "format": "int64", "type": "integer" @@ -3273,6 +3416,9 @@ }, "token": { "$ref": "#/definitions/Secret" + }, + "token_file": { + "type": "string" } }, "title": "TelegramConfig configures notifications via Telegram.", @@ -3535,6 +3681,7 @@ "type": "object" }, "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", "properties": { "ForceQuery": { "type": "boolean" @@ -3570,7 +3717,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "URL is a custom URL type that allows validation at configuration load time.", + "title": "A URL represents a parsed URL (technically, a URI reference).", "type": "object" }, "Userinfo": { @@ -3684,7 +3831,10 @@ "type": "boolean" }, "url": { - "$ref": "#/definitions/URL" + "$ref": "#/definitions/SecretURL" + }, + "url_file": { + "type": "string" } }, "title": "WebhookConfig configures notifications via a generic webhook.", @@ -3747,7 +3897,6 @@ "type": "object" }, "alertGroup": { - "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -3771,7 +3920,6 @@ "type": "object" }, "alertGroups": { - "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup" }, @@ -3876,6 +4024,7 @@ "type": "object" }, "gettableAlert": { + "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -3931,12 +4080,14 @@ "type": "object" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert" }, "type": "array" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -3985,7 +4136,6 @@ "type": "object" }, "gettableSilences": { - "description": "GettableSilences gettable silences", "items": { "$ref": "#/definitions/gettableSilence" }, @@ -4136,6 +4286,7 @@ "type": "array" }, "postableSilence": { + "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -4173,6 +4324,7 @@ "type": "object" }, "receiver": { + "description": "Receiver receiver", "properties": { "active": { "description": "active", @@ -6926,7 +7078,7 @@ "in": "body", "name": "Body", "schema": { - "$ref": "#/definitions/TestRulePayload" + "$ref": "#/definitions/PostableExtendedRuleNodeExtended" } } ], @@ -6935,9 +7087,18 @@ ], "responses": { "200": { - "description": "TestRuleResponse", + "$ref": "#/responses/TestGrafanaRuleResponse" + }, + "400": { + "description": "ValidationError", "schema": { - "$ref": "#/definitions/TestRuleResponse" + "$ref": "#/definitions/ValidationError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" } } }, @@ -7022,6 +7183,21 @@ "type": "array" } }, + "StateHistory": { + "description": "", + "schema": { + "$ref": "#/definitions/Frame" + } + }, + "TestGrafanaRuleResponse": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/postableAlert" + }, + "type": "array" + } + }, "receiversResponse": { "description": "", "schema": { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 5e2113b840d..400387ec389 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -2683,15 +2683,24 @@ "name": "Body", "in": "body", "schema": { - "$ref": "#/definitions/TestRulePayload" + "$ref": "#/definitions/PostableExtendedRuleNodeExtended" } } ], "responses": { "200": { - "description": "TestRuleResponse", + "$ref": "#/responses/TestGrafanaRuleResponse" + }, + "400": { + "description": "ValidationError", "schema": { - "$ref": "#/definitions/TestRuleResponse" + "$ref": "#/definitions/ValidationError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" } } } @@ -3300,6 +3309,12 @@ "$ref": "#/definitions/EmbeddedContactPoint" } }, + "CounterResetHint": { + "description": "or alternatively that we are dealing with a gauge histogram, where counter resets do not apply.", + "type": "integer", + "format": "uint8", + "title": "CounterResetHint contains the known information about a counter reset," + }, "DataLink": { "description": "DataLink define what", "type": "object", @@ -3651,7 +3666,7 @@ "type": "string" }, "displayNameFromDS": { - "description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.", + "description": "DisplayNameFromDS overrides Grafana default naming strategy.", "type": "string" }, "filterable": { @@ -3712,6 +3727,56 @@ } } }, + "FloatHistogram": { + "description": "A FloatHistogram is needed by PromQL to handle operations that might result\nin fractional counts. Since the counts in a histogram are unlikely to be too\nlarge to be represented precisely by a float64, a FloatHistogram can also be\nused to represent a histogram with integer counts and thus serves as a more\ngeneralized representation.", + "type": "object", + "title": "FloatHistogram is similar to Histogram but uses float64 for all\ncounts. Additionally, bucket counts are absolute and not deltas.", + "properties": { + "Count": { + "description": "Total number of observations. Must be zero or positive.", + "type": "number", + "format": "double" + }, + "CounterResetHint": { + "$ref": "#/definitions/CounterResetHint" + }, + "PositiveBuckets": { + "description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.", + "type": "array", + "items": { + "type": "number", + "format": "double" + } + }, + "PositiveSpans": { + "description": "Spans for positive and negative buckets (see Span below).", + "type": "array", + "items": { + "$ref": "#/definitions/Span" + } + }, + "Schema": { + "description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).", + "type": "integer", + "format": "int32" + }, + "Sum": { + "description": "Sum of observations. This is also used as the stale marker.", + "type": "number", + "format": "double" + }, + "ZeroCount": { + "description": "Observations falling into the zero bucket. Must be zero or positive.", + "type": "number", + "format": "double" + }, + "ZeroThreshold": { + "description": "Width of the zero bucket.", + "type": "number", + "format": "double" + } + } + }, "Frame": { "description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.", "type": "object", @@ -4315,12 +4380,20 @@ "description": "FollowRedirects specifies whether the client should follow HTTP 3xx redirects.\nThe omitempty flag is not set, because it would be hidden from the\nmarshalled configuration when set to false.", "type": "boolean" }, + "no_proxy": { + "description": "NoProxy contains addresses that should not use a proxy.", + "type": "string" + }, "oauth2": { "$ref": "#/definitions/OAuth2" }, "proxy_connect_header": { "$ref": "#/definitions/Header" }, + "proxy_from_environment": { + "description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.", + "type": "boolean" + }, "proxy_url": { "$ref": "#/definitions/URL" }, @@ -4628,6 +4701,17 @@ "type": "string" } }, + "no_proxy": { + "description": "NoProxy contains addresses that should not use a proxy.", + "type": "string" + }, + "proxy_connect_header": { + "$ref": "#/definitions/Header" + }, + "proxy_from_environment": { + "description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.", + "type": "boolean" + }, "proxy_url": { "$ref": "#/definitions/URL" }, @@ -4825,9 +4909,13 @@ "type": "object" }, "Point": { + "description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.", "type": "object", "title": "Point represents a single data point for a given timestamp.", "properties": { + "H": { + "$ref": "#/definitions/FloatHistogram" + }, "T": { "type": "integer", "format": "int64" @@ -4993,6 +5081,29 @@ } } }, + "PostableExtendedRuleNodeExtended": { + "type": "object", + "required": [ + "rule" + ], + "properties": { + "folderTitle": { + "type": "string", + "example": "project_x" + }, + "folderUid": { + "type": "string", + "example": "okrd3I0Vz" + }, + "rule": { + "$ref": "#/definitions/PostableExtendedRuleNode" + }, + "ruleGroup": { + "type": "string", + "example": "eval_group_1" + } + } + }, "PostableGrafanaReceiver": { "type": "object", "properties": { @@ -5269,9 +5380,31 @@ "$ref": "#/definitions/ProvisionedAlertRule" } }, + "ProxyConfig": { + "type": "object", + "properties": { + "no_proxy": { + "description": "NoProxy contains addresses that should not use a proxy.", + "type": "string" + }, + "proxy_connect_header": { + "$ref": "#/definitions/Header" + }, + "proxy_from_environment": { + "description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.", + "type": "boolean" + }, + "proxy_url": { + "$ref": "#/definitions/URL" + } + } + }, "PushoverConfig": { "type": "object", "properties": { + "device": { + "type": "string" + }, "expire": { "type": "string" }, @@ -5347,7 +5480,7 @@ "type": "string" }, "displayNameFromDS": { - "description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.", + "description": "DisplayNameFromDS overrides Grafana default naming strategy.", "type": "string" }, "filterable": { @@ -5770,6 +5903,9 @@ "type": "object", "title": "Sample is a single sample belonging to a metric.", "properties": { + "H": { + "$ref": "#/definitions/FloatHistogram" + }, "Metric": { "$ref": "#/definitions/Labels" }, @@ -5962,6 +6098,22 @@ "SmtpNotEnabled": { "$ref": "#/definitions/ResponseDetails" }, + "Span": { + "type": "object", + "title": "A Span defines a continuous sequence of buckets.", + "properties": { + "Length": { + "description": "Length of the span.", + "type": "integer", + "format": "uint32" + }, + "Offset": { + "description": "Gap to previous span (always positive), or starting index for the 1st\nspan (which can be negative).", + "type": "integer", + "format": "int32" + } + } + }, "Status": { "type": "integer", "format": "int64" @@ -6036,6 +6188,9 @@ }, "token": { "$ref": "#/definitions/Secret" + }, + "token_file": { + "type": "string" } } }, @@ -6296,8 +6451,9 @@ } }, "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", "type": "object", - "title": "URL is a custom URL type that allows validation at configuration load time.", + "title": "A URL represents a parsed URL (technically, a URI reference).", "properties": { "ForceQuery": { "type": "boolean" @@ -6447,7 +6603,10 @@ "type": "boolean" }, "url": { - "$ref": "#/definitions/URL" + "$ref": "#/definitions/SecretURL" + }, + "url_file": { + "type": "string" } } }, @@ -6508,7 +6667,6 @@ } }, "alertGroup": { - "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -6533,7 +6691,6 @@ "$ref": "#/definitions/alertGroup" }, "alertGroups": { - "description": "AlertGroups alert groups", "type": "array", "items": { "$ref": "#/definitions/alertGroup" @@ -6639,6 +6796,7 @@ } }, "gettableAlert": { + "description": "GettableAlert gettable alert", "type": "object", "required": [ "labels", @@ -6695,6 +6853,7 @@ "$ref": "#/definitions/gettableAlert" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "type": "array", "items": { "$ref": "#/definitions/gettableAlert" @@ -6702,6 +6861,7 @@ "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -6751,7 +6911,6 @@ "$ref": "#/definitions/gettableSilence" }, "gettableSilences": { - "description": "GettableSilences gettable silences", "type": "array", "items": { "$ref": "#/definitions/gettableSilence" @@ -6904,6 +7063,7 @@ } }, "postableSilence": { + "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -6942,6 +7102,7 @@ "$ref": "#/definitions/postableSilence" }, "receiver": { + "description": "Receiver receiver", "type": "object", "required": [ "active", @@ -7066,6 +7227,21 @@ } } }, + "StateHistory": { + "description": "", + "schema": { + "$ref": "#/definitions/Frame" + } + }, + "TestGrafanaRuleResponse": { + "description": "", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/postableAlert" + } + } + }, "receiversResponse": { "description": "", "schema": { diff --git a/pkg/services/ngalert/notifier/templates.go b/pkg/services/ngalert/notifier/templates.go index 0f34a7e60bd..ccd8ecdb7df 100644 --- a/pkg/services/ngalert/notifier/templates.go +++ b/pkg/services/ngalert/notifier/templates.go @@ -41,7 +41,7 @@ func (am *Alertmanager) TestTemplate(ctx context.Context, c apimodels.TestTempla }) } -// addDefaultLabelsAndAnnotations is a slimmed down version of schedule.stateToPostableAlert and schedule.getRuleExtraLabels using default values. +// addDefaultLabelsAndAnnotations is a slimmed down version of state.StateToPostableAlert and state.GetRuleExtraLabels using default values. func addDefaultLabelsAndAnnotations(alert *amv2.PostableAlert) { if alert.Labels == nil { alert.Labels = make(map[string]string) diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index 16523eb8754..69cbbd97128 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -8,9 +8,7 @@ import ( "time" "github.com/benbjohnson/clock" - alertingModels "github.com/grafana/alerting/models" "github.com/hashicorp/go-multierror" - prometheusModel "github.com/prometheus/common/model" "go.opentelemetry.io/otel/attribute" "golang.org/x/sync/errgroup" @@ -355,7 +353,7 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR evalTotalFailures := sch.metrics.EvalFailures.WithLabelValues(orgID) notify := func(states []state.StateTransition) { - expiredAlerts := FromAlertsStateToStoppedAlert(states, sch.appURL, sch.clock) + expiredAlerts := state.FromAlertsStateToStoppedAlert(states, sch.appURL, sch.clock) if len(expiredAlerts.PostableAlerts) > 0 { sch.alertsSender.Send(key, expiredAlerts) } @@ -425,8 +423,14 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR logger.Debug("Skip updating the state because the context has been cancelled") return } - processedStates := sch.stateManager.ProcessEvalResults(ctx, e.scheduledAt, e.rule, results, sch.getRuleExtraLabels(e)) - alerts := FromStateTransitionToPostableAlerts(processedStates, sch.stateManager, sch.appURL) + processedStates := sch.stateManager.ProcessEvalResults( + ctx, + e.scheduledAt, + e.rule, + results, + state.GetRuleExtraLabels(e.rule, e.folderTitle, !sch.disableGrafanaFolder), + ) + alerts := state.FromStateTransitionToPostableAlerts(processedStates, sch.stateManager, sch.appURL) span.AddEvents( []string{"message", "state_transitions", "alerts_to_send"}, []tracing.EventValue{ @@ -558,19 +562,6 @@ func (sch *schedule) stopApplied(alertDefKey ngmodels.AlertRuleKey) { sch.stopAppliedFunc(alertDefKey) } -func (sch *schedule) getRuleExtraLabels(evalCtx *evaluation) map[string]string { - extraLabels := make(map[string]string, 4) - - extraLabels[alertingModels.NamespaceUIDLabel] = evalCtx.rule.NamespaceUID - extraLabels[prometheusModel.AlertNameLabel] = evalCtx.rule.Title - extraLabels[alertingModels.RuleUIDLabel] = evalCtx.rule.UID - - if !sch.disableGrafanaFolder { - extraLabels[ngmodels.FolderTitleLabel] = evalCtx.folderTitle - } - return extraLabels -} - func SchedulerUserFor(orgID int64) *user.SignedInUser { return &user.SignedInUser{ UserID: -1, diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index 1e003cc4d93..53280768f91 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -676,7 +676,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { args, ok := sender.Calls[0].Arguments[1].(definitions.PostableAlerts) require.Truef(t, ok, fmt.Sprintf("expected argument of function was supposed to be 'definitions.PostableAlerts' but got %T", sender.Calls[0].Arguments[1])) assert.Len(t, args.PostableAlerts, 1) - assert.Equal(t, ErrorAlertName, args.PostableAlerts[0].Labels[prometheusModel.AlertNameLabel]) + assert.Equal(t, state.ErrorAlertName, args.PostableAlerts[0].Labels[prometheusModel.AlertNameLabel]) }) }) diff --git a/pkg/services/ngalert/schedule/compat.go b/pkg/services/ngalert/state/compat.go similarity index 87% rename from pkg/services/ngalert/schedule/compat.go rename to pkg/services/ngalert/state/compat.go index 14c87ae663a..70d97e474d8 100644 --- a/pkg/services/ngalert/schedule/compat.go +++ b/pkg/services/ngalert/state/compat.go @@ -1,4 +1,4 @@ -package schedule +package state import ( "encoding/json" @@ -18,7 +18,6 @@ import ( apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/state" ) const ( @@ -28,13 +27,13 @@ const ( Rulename = "rulename" ) -// stateToPostableAlert converts a state to a model that is accepted by Alertmanager. Annotations and Labels are copied from the state. +// StateToPostableAlert converts a state to a model that is accepted by Alertmanager. Annotations and Labels are copied from the state. // - if state has at least one result, a new label '__value_string__' is added to the label set // - the alert's GeneratorURL is constructed to point to the alert detail view // - if evaluation state is either NoData or Error, the resulting set of labels is changed: // - original alert name (label: model.AlertNameLabel) is backed up to OriginalAlertName // - label model.AlertNameLabel is overwritten to either NoDataAlertName or ErrorAlertName -func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.PostableAlert { +func StateToPostableAlert(alertState *State, appURL *url.URL) *models.PostableAlert { nL := alertState.Labels.Copy() nA := data.Labels(alertState.Annotations).Copy() @@ -95,7 +94,7 @@ func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.Post // It effectively replaces the legacy behavior of "Keep Last State" by separating the regular alerting flow from the no data scenario into a separate alerts. // The Alert is defined as: // { alertname=DatasourceNoData rulename=original_alertname } + { rule labelset } + { rule annotations } -func noDataAlert(labels data.Labels, annotations data.Labels, alertState *state.State, urlStr string) *models.PostableAlert { +func noDataAlert(labels data.Labels, annotations data.Labels, alertState *State, urlStr string) *models.PostableAlert { if name, ok := labels[model.AlertNameLabel]; ok { labels[Rulename] = name } @@ -114,7 +113,7 @@ func noDataAlert(labels data.Labels, annotations data.Labels, alertState *state. // errorAlert is a special alert sent when evaluation of an alert rule failed due to an error. Like noDataAlert, it // replaces the old behaviour of "Keep Last State" creating a separate alert called DatasourceError. -func errorAlert(labels, annotations data.Labels, alertState *state.State, urlStr string) *models.PostableAlert { +func errorAlert(labels, annotations data.Labels, alertState *State, urlStr string) *models.PostableAlert { if name, ok := labels[model.AlertNameLabel]; ok { labels[Rulename] = name } @@ -131,16 +130,16 @@ func errorAlert(labels, annotations data.Labels, alertState *state.State, urlStr } } -func FromStateTransitionToPostableAlerts(firingStates []state.StateTransition, stateManager *state.Manager, appURL *url.URL) apimodels.PostableAlerts { +func FromStateTransitionToPostableAlerts(firingStates []StateTransition, stateManager *Manager, appURL *url.URL) apimodels.PostableAlerts { alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))} - var sentAlerts []*state.State + var sentAlerts []*State ts := time.Now() for _, alertState := range firingStates { if !alertState.NeedsSending(stateManager.ResendDelay) { continue } - alert := stateToPostableAlert(alertState.State, appURL) + alert := StateToPostableAlert(alertState.State, appURL) alerts.PostableAlerts = append(alerts.PostableAlerts, *alert) if alertState.StateReason == ngModels.StateReasonMissingSeries { // do not put stale state back to state manager continue @@ -154,14 +153,14 @@ func FromStateTransitionToPostableAlerts(firingStates []state.StateTransition, s // FromAlertsStateToStoppedAlert selects only transitions from firing states (states eval.Alerting, eval.NoData, eval.Error) // and converts them to models.PostableAlert with EndsAt set to time.Now -func FromAlertsStateToStoppedAlert(firingStates []state.StateTransition, appURL *url.URL, clock clock.Clock) apimodels.PostableAlerts { +func FromAlertsStateToStoppedAlert(firingStates []StateTransition, appURL *url.URL, clock clock.Clock) apimodels.PostableAlerts { alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))} ts := clock.Now() for _, transition := range firingStates { if transition.PreviousState == eval.Normal || transition.PreviousState == eval.Pending { continue } - postableAlert := stateToPostableAlert(transition.State, appURL) + postableAlert := StateToPostableAlert(transition.State, appURL) postableAlert.EndsAt = strfmt.DateTime(ts) alerts.PostableAlerts = append(alerts.PostableAlerts, *postableAlert) } diff --git a/pkg/services/ngalert/schedule/compat_test.go b/pkg/services/ngalert/state/compat_test.go similarity index 87% rename from pkg/services/ngalert/schedule/compat_test.go rename to pkg/services/ngalert/state/compat_test.go index 7e61fd3d055..a33551cc27f 100644 --- a/pkg/services/ngalert/schedule/compat_test.go +++ b/pkg/services/ngalert/state/compat_test.go @@ -1,4 +1,4 @@ -package schedule +package state import ( "fmt" @@ -16,11 +16,10 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/eval" ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/util" ) -func Test_stateToPostableAlert(t *testing.T) { +func Test_StateToPostableAlert(t *testing.T) { appURL := &url.URL{ Scheme: "http:", Host: fmt.Sprintf("host-%d", rand.Int()), @@ -59,7 +58,7 @@ func Test_stateToPostableAlert(t *testing.T) { t.Run("to alert rule", func(t *testing.T) { alertState := randomState(tc.state) alertState.Labels[alertingModels.RuleUIDLabel] = alertState.AlertRuleUID - result := stateToPostableAlert(alertState, appURL) + result := StateToPostableAlert(alertState, appURL) u := *appURL u.Path = u.Path + "/alerting/grafana/" + alertState.AlertRuleUID + "/view" require.Equal(t, u.String(), result.Alert.GeneratorURL.String()) @@ -68,25 +67,25 @@ func Test_stateToPostableAlert(t *testing.T) { t.Run("app URL as is if rule UID is not specified", func(t *testing.T) { alertState := randomState(tc.state) alertState.Labels[alertingModels.RuleUIDLabel] = "" - result := stateToPostableAlert(alertState, appURL) + result := StateToPostableAlert(alertState, appURL) require.Equal(t, appURL.String(), result.Alert.GeneratorURL.String()) delete(alertState.Labels, alertingModels.RuleUIDLabel) - result = stateToPostableAlert(alertState, appURL) + result = StateToPostableAlert(alertState, appURL) require.Equal(t, appURL.String(), result.Alert.GeneratorURL.String()) }) t.Run("empty string if app URL is not provided", func(t *testing.T) { alertState := randomState(tc.state) alertState.Labels[alertingModels.RuleUIDLabel] = alertState.AlertRuleUID - result := stateToPostableAlert(alertState, nil) + result := StateToPostableAlert(alertState, nil) require.Equal(t, "", result.Alert.GeneratorURL.String()) }) }) t.Run("Start and End timestamps should be the same", func(t *testing.T) { alertState := randomState(tc.state) - result := stateToPostableAlert(alertState, appURL) + result := StateToPostableAlert(alertState, appURL) require.Equal(t, strfmt.DateTime(alertState.StartsAt), result.StartsAt) require.Equal(t, strfmt.DateTime(alertState.EndsAt), result.EndsAt) }) @@ -94,7 +93,7 @@ func Test_stateToPostableAlert(t *testing.T) { t.Run("should copy annotations", func(t *testing.T) { alertState := randomState(tc.state) alertState.Annotations = randomMapOfStrings() - result := stateToPostableAlert(alertState, appURL) + result := StateToPostableAlert(alertState, appURL) require.Equal(t, models.LabelSet(alertState.Annotations), result.Annotations) t.Run("add __value_string__ if it has results", func(t *testing.T) { @@ -103,7 +102,7 @@ func Test_stateToPostableAlert(t *testing.T) { expectedValueString := util.GenerateShortUID() alertState.LastEvaluationString = expectedValueString - result := stateToPostableAlert(alertState, appURL) + result := StateToPostableAlert(alertState, appURL) expected := make(models.LabelSet, len(alertState.Annotations)+1) for k, v := range alertState.Annotations { @@ -115,7 +114,7 @@ func Test_stateToPostableAlert(t *testing.T) { // even overwrites alertState.Annotations["__value_string__"] = util.GenerateShortUID() - result = stateToPostableAlert(alertState, appURL) + result = StateToPostableAlert(alertState, appURL) require.Equal(t, expected, result.Annotations) }) @@ -124,7 +123,7 @@ func Test_stateToPostableAlert(t *testing.T) { alertState.Annotations = randomMapOfStrings() alertState.Image = &ngModels.Image{Token: "test_token"} - result := stateToPostableAlert(alertState, appURL) + result := StateToPostableAlert(alertState, appURL) expected := make(models.LabelSet, len(alertState.Annotations)+1) for k, v := range alertState.Annotations { @@ -139,7 +138,7 @@ func Test_stateToPostableAlert(t *testing.T) { t.Run("should add state reason annotation if not empty", func(t *testing.T) { alertState := randomState(tc.state) alertState.StateReason = "TEST_STATE_REASON" - result := stateToPostableAlert(alertState, appURL) + result := StateToPostableAlert(alertState, appURL) require.Equal(t, alertState.StateReason, result.Annotations[ngModels.StateReasonAnnotation]) }) @@ -151,7 +150,7 @@ func Test_stateToPostableAlert(t *testing.T) { alertName := util.GenerateShortUID() alertState.Labels[model.AlertNameLabel] = alertName - result := stateToPostableAlert(alertState, appURL) + result := StateToPostableAlert(alertState, appURL) expected := make(models.LabelSet, len(alertState.Labels)+1) for k, v := range alertState.Labels { @@ -167,7 +166,7 @@ func Test_stateToPostableAlert(t *testing.T) { alertState.Labels = randomMapOfStrings() delete(alertState.Labels, model.AlertNameLabel) - result := stateToPostableAlert(alertState, appURL) + result := StateToPostableAlert(alertState, appURL) require.Equal(t, NoDataAlertName, result.Labels[model.AlertNameLabel]) require.NotContains(t, result.Labels[model.AlertNameLabel], Rulename) @@ -180,7 +179,7 @@ func Test_stateToPostableAlert(t *testing.T) { alertName := util.GenerateShortUID() alertState.Labels[model.AlertNameLabel] = alertName - result := stateToPostableAlert(alertState, appURL) + result := StateToPostableAlert(alertState, appURL) expected := make(models.LabelSet, len(alertState.Labels)+1) for k, v := range alertState.Labels { @@ -196,7 +195,7 @@ func Test_stateToPostableAlert(t *testing.T) { alertState.Labels = randomMapOfStrings() delete(alertState.Labels, model.AlertNameLabel) - result := stateToPostableAlert(alertState, appURL) + result := StateToPostableAlert(alertState, appURL) require.Equal(t, ErrorAlertName, result.Labels[model.AlertNameLabel]) require.NotContains(t, result.Labels[model.AlertNameLabel], Rulename) @@ -206,7 +205,7 @@ func Test_stateToPostableAlert(t *testing.T) { t.Run("should copy labels as is", func(t *testing.T) { alertState := randomState(tc.state) alertState.Labels = randomMapOfStrings() - result := stateToPostableAlert(alertState, appURL) + result := StateToPostableAlert(alertState, appURL) require.Equal(t, models.LabelSet(alertState.Labels), result.Labels) }) } @@ -222,10 +221,10 @@ func Test_FromAlertsStateToStoppedAlert(t *testing.T) { } evalStates := [...]eval.State{eval.Normal, eval.Alerting, eval.Pending, eval.Error, eval.NoData} - states := make([]state.StateTransition, 0, len(evalStates)*len(evalStates)) + states := make([]StateTransition, 0, len(evalStates)*len(evalStates)) for _, to := range evalStates { for _, from := range evalStates { - states = append(states, state.StateTransition{ + states = append(states, StateTransition{ State: randomState(to), PreviousState: from, }) @@ -240,7 +239,7 @@ func Test_FromAlertsStateToStoppedAlert(t *testing.T) { if !(s.PreviousState == eval.Alerting || s.PreviousState == eval.Error || s.PreviousState == eval.NoData) { continue } - alert := stateToPostableAlert(s.State, appURL) + alert := StateToPostableAlert(s.State, appURL) alert.EndsAt = strfmt.DateTime(clk.Now()) expected = append(expected, *alert) } @@ -271,8 +270,8 @@ func randomTimeInPast() time.Time { return time.Now().Add(-randomDuration()) } -func randomState(evalState eval.State) *state.State { - return &state.State{ +func randomState(evalState eval.State) *State { + return &State{ State: evalState, AlertRuleUID: util.GenerateShortUID(), StartsAt: time.Now(), diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 88d62492c93..7b40fac3b96 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -8,7 +8,9 @@ import ( "strings" "time" + alertingModels "github.com/grafana/alerting/models" "github.com/grafana/grafana-plugin-sdk-go/data" + prometheusModel "github.com/prometheus/common/model" "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/log" @@ -397,3 +399,17 @@ func FormatStateAndReason(state eval.State, reason string) string { } return s } + +// GetRuleExtraLabels returns a map of built-in labels that should be added to an alert before it is sent to the Alertmanager or its state is cached. +func GetRuleExtraLabels(rule *models.AlertRule, folderTitle string, includeFolder bool) map[string]string { + extraLabels := make(map[string]string, 4) + + extraLabels[alertingModels.NamespaceUIDLabel] = rule.NamespaceUID + extraLabels[prometheusModel.AlertNameLabel] = rule.Title + extraLabels[alertingModels.RuleUIDLabel] = rule.UID + + if includeFolder { + extraLabels[models.FolderTitleLabel] = folderTitle + } + return extraLabels +} diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index f4091febf43..5602c9d4670 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -2119,237 +2119,6 @@ func TestIntegrationEval(t *testing.T) { expectedStatusCode func() int expectedResponse func() string expectedMessage func() string - }{ - { - desc: "alerting condition", - payload: ` - { - "grafana_condition": { - "condition": "A", - "data": [ - { - "refId": "A", - "relativeTimeRange": { - "from": 18000, - "to": 10800 - }, - "datasourceUid":"__expr__", - "model": { - "type":"math", - "expression":"1 < 2" - } - } - ], - "now": "2021-04-11T14:38:14Z" - } - } - `, - expectedMessage: func() string { return "" }, - expectedStatusCode: func() int { return http.StatusOK }, - expectedResponse: func() string { - return `{ - "instances": [ - { - "schema": { - "name": "evaluation results", - "fields": [ - { - "name": "State", - "type": "string", - "typeInfo": { - "frame": "string" - } - }, - { - "name": "Info", - "type": "string", - "typeInfo": { - "frame": "string" - } - } - ] - }, - "data": { - "values": [ - [ - "Alerting" - ], - [ - "[ var='A' labels={} value=1 ]" - ] - ] - } - } - ] - }` - }, - }, - { - desc: "normal condition", - payload: ` - { - "grafana_condition": { - "condition": "A", - "data": [ - { - "refId": "A", - "relativeTimeRange": { - "from": 18000, - "to": 10800 - }, - "datasourceUid": "__expr__", - "model": { - "type":"math", - "expression":"1 > 2" - } - } - ], - "now": "2021-04-11T14:38:14Z" - } - } - `, - expectedMessage: func() string { return "" }, - expectedStatusCode: func() int { return http.StatusOK }, - expectedResponse: func() string { - return `{ - "instances": [ - { - "schema": { - "name": "evaluation results", - "fields": [ - { - "name": "State", - "type": "string", - "typeInfo": { - "frame": "string" - } - }, - { - "name": "Info", - "type": "string", - "typeInfo": { - "frame": "string" - } - } - ] - }, - "data": { - "values": [ - [ - "Normal" - ], - [ - "[ var='A' labels={} value=0 ]" - ] - ] - } - } - ] - }` - }, - }, - { - desc: "condition not found in any query or expression", - payload: ` - { - "grafana_condition": { - "condition": "B", - "data": [ - { - "refId": "A", - "relativeTimeRange": { - "from": 18000, - "to": 10800 - }, - "datasourceUid": "__expr__", - "model": { - "type":"math", - "expression":"1 > 2" - } - } - ], - "now": "2021-04-11T14:38:14Z" - } - } - `, - expectedStatusCode: func() int { return http.StatusBadRequest }, - expectedMessage: func() string { - return "invalid condition: condition B does not exist, must be one of [A]" - }, - expectedResponse: func() string { return "" }, - }, - { - desc: "unknown query datasource", - payload: ` - { - "grafana_condition": { - "condition": "A", - "data": [ - { - "refId": "A", - "relativeTimeRange": { - "from": 18000, - "to": 10800 - }, - "datasourceUid": "unknown", - "model": { - } - } - ], - "now": "2021-04-11T14:38:14Z" - } - } - `, - expectedStatusCode: func() int { - if setting.IsEnterprise { - return http.StatusUnauthorized - } - return http.StatusBadRequest - }, - expectedMessage: func() string { - if setting.IsEnterprise { - return "user is not authorized to query one or many data sources used by the rule" - } - return "invalid condition: failed to build query 'A': data source not found" - }, - expectedResponse: func() string { return "" }, - }, - } - - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - u := fmt.Sprintf("http://grafana:password@%s/api/v1/rule/test/grafana", grafanaListedAddr) - r := strings.NewReader(tc.payload) - // nolint:gosec - resp, err := http.Post(u, "application/json", r) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - res := Response{} - err = json.Unmarshal(b, &res) - require.NoError(t, err) - - assert.Equal(t, tc.expectedStatusCode(), resp.StatusCode) - if tc.expectedResponse() != "" { - require.JSONEq(t, tc.expectedResponse(), string(b)) - } - if tc.expectedMessage() != "" { - assert.Equal(t, tc.expectedMessage(), res.Message) - } - }) - } - - // test eval queries and expressions - testCases = []struct { - desc string - payload string - expectedStatusCode func() int - expectedResponse func() string - expectedMessage func() string }{ { desc: "alerting condition", diff --git a/pkg/tests/api/alerting/api_testing_test.go b/pkg/tests/api/alerting/api_testing_test.go new file mode 100644 index 00000000000..1a0d85bfd29 --- /dev/null +++ b/pkg/tests/api/alerting/api_testing_test.go @@ -0,0 +1,408 @@ +package alerting + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + alertingModels "github.com/grafana/alerting/models" + amv2 "github.com/prometheus/alertmanager/api/v2/models" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/datasources" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/util" +) + +const ( + TESTDATA_UID = "testdata" +) + +func TestGrafanaRuleConfig(t *testing.T) { + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + EnableFeatureToggles: []string{}, + EnableLog: false, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + userId := createUser(t, env.SQLStore, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + + apiCli := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + + dsCmd := &datasources.AddDataSourceCommand{ + Name: "TestDatasource", + Type: "testdata", + Access: datasources.DS_ACCESS_PROXY, + UID: TESTDATA_UID, + UserID: userId, + OrgID: 1, + } + _, err := env.Server.HTTPServer.DataSourcesService.AddDataSource(context.Background(), dsCmd) + require.NoError(t, err) + + dynamicLabels := []string{"GA", "FL", "AL", "AZ"} + dynamicLabelsJson, _ := json.Marshal(&dynamicLabels) + testdataQueryModel := json.RawMessage(fmt.Sprintf(`{ + "refId": "A", + "hide": false, + "scenarioId": "usa", + "usa": { + "mode": "timeseries", + "period": "1m", + "states": %s, + "fields": [ + "baz" + ] + } + }`, string(dynamicLabelsJson))) + + genRule := func(ruleGen func() apimodels.PostableExtendedRuleNode) apimodels.PostableExtendedRuleNodeExtended { + return apimodels.PostableExtendedRuleNodeExtended{ + Rule: ruleGen(), + NamespaceUID: "NamespaceUID", + NamespaceTitle: "NamespaceTitle", + } + } + + t.Run("valid rule should accept request", func(t *testing.T) { + status, body := apiCli.SubmitRuleForTesting(t, genRule(alertRuleGen())) + require.Equal(t, http.StatusOK, status) + var result []amv2.PostableAlert + require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") + }) + + t.Run("valid rule should return alerts in response", func(t *testing.T) { + status, body := apiCli.SubmitRuleForTesting(t, genRule(alertRuleGen())) + require.Equal(t, http.StatusOK, status) + var result []amv2.PostableAlert + require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") + require.Len(t, result, 1) + }) + + t.Run("valid rule should return static annotations", func(t *testing.T) { + rule := genRule(testdataRule(testdataQueryModel, nil, nil)) + rule.Rule.Annotations = map[string]string{ + "foo": "bar", + "foo2": "bar2", + } + status, body := apiCli.SubmitRuleForTesting(t, rule) + require.Equal(t, http.StatusOK, status) + var result []amv2.PostableAlert + require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") + require.Len(t, result, 4) + for _, alert := range result { + require.Equal(t, "bar", alert.Annotations["foo"]) + require.Equal(t, "bar2", alert.Annotations["foo2"]) + } + }) + + t.Run("valid rule should return static labels", func(t *testing.T) { + rule := genRule(testdataRule(testdataQueryModel, nil, nil)) + rule.Rule.Labels = map[string]string{ + "foo": "bar", + "foo2": "bar2", + } + status, body := apiCli.SubmitRuleForTesting(t, rule) + require.Equal(t, http.StatusOK, status) + var result []amv2.PostableAlert + require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") + require.Len(t, result, 4) + for _, alert := range result { + require.Equal(t, "bar", alert.Labels["foo"]) + require.Equal(t, "bar2", alert.Labels["foo2"]) + } + }) + + t.Run("valid rule should return interpolated annotations", func(t *testing.T) { + rule := genRule(testdataRule(testdataQueryModel, nil, nil)) + rule.Rule.Annotations = map[string]string{ + "value": "{{ $value }}", + "values.B": "{{ $values.B }}", + "values.C": "{{ $values.C }}", + } + status, body := apiCli.SubmitRuleForTesting(t, rule) + require.Equal(t, http.StatusOK, status) + var result []amv2.PostableAlert + require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") + require.Len(t, result, 4) + for i, alert := range result { + require.NotEmpty(t, alert.Annotations["values.B"]) + require.NotEmpty(t, alert.Annotations["values.C"]) + valueB := fmt.Sprintf("[ var='B' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Annotations["values.B"]) + valueC := fmt.Sprintf("[ var='C' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Annotations["values.C"]) + require.Contains(t, alert.Annotations["value"], valueB) + require.Contains(t, alert.Annotations["value"], valueC) + } + }) + + t.Run("valid rule should return interpolated labels", func(t *testing.T) { + rule := genRule(testdataRule(testdataQueryModel, nil, nil)) + rule.Rule.Labels = map[string]string{ + "value": "{{ $value }}", + "values.B": "{{ $values.B }}", + "values.C": "{{ $values.C }}", + } + status, body := apiCli.SubmitRuleForTesting(t, rule) + require.Equal(t, http.StatusOK, status) + var result []amv2.PostableAlert + require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") + require.Len(t, result, 4) + for i, alert := range result { + require.NotEmpty(t, alert.Labels["values.B"]) + require.NotEmpty(t, alert.Labels["values.C"]) + valueB := fmt.Sprintf("[ var='B' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Labels["values.B"]) + valueC := fmt.Sprintf("[ var='C' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Labels["values.C"]) + require.Contains(t, alert.Labels["value"], valueB) + require.Contains(t, alert.Labels["value"], valueC) + } + }) + + t.Run("valid rule should use functions with annotations", func(t *testing.T) { + rule := genRule(testdataRule(testdataQueryModel, nil, nil)) + rule.Rule.Annotations = map[string]string{ + "externalURL": "{{ externalURL }}", + "humanize": "{{ humanize 1000.0 }}", + } + status, body := apiCli.SubmitRuleForTesting(t, rule) + require.Equal(t, http.StatusOK, status) + var result []amv2.PostableAlert + require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") + require.Len(t, result, 4) + for _, alert := range result { + require.Equal(t, "http://localhost:3000/", alert.Annotations["externalURL"]) + require.Equal(t, "1k", alert.Annotations["humanize"]) + } + }) + + t.Run("valid rule should use functions with labels", func(t *testing.T) { + rule := genRule(testdataRule(testdataQueryModel, nil, nil)) + rule.Rule.Labels = map[string]string{ + "externalURL": "{{ externalURL }}", + "humanize": "{{ humanize 1000.0 }}", + } + status, body := apiCli.SubmitRuleForTesting(t, rule) + require.Equal(t, http.StatusOK, status) + var result []amv2.PostableAlert + require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") + require.Len(t, result, 4) + for _, alert := range result { + require.Equal(t, "http://localhost:3000/", alert.Labels["externalURL"]) + require.Equal(t, "1k", alert.Labels["humanize"]) + } + }) + + t.Run("valid rule should return dynamic labels", func(t *testing.T) { + rule := genRule(testdataRule(testdataQueryModel, nil, nil)) + status, body := apiCli.SubmitRuleForTesting(t, rule) + require.Equal(t, http.StatusOK, status) + var result []amv2.PostableAlert + require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") + require.Len(t, result, 4) + for i, alert := range result { + require.Equal(t, dynamicLabels[i], alert.Labels["state"]) + } + }) + + t.Run("valid rule should return built-in labels", func(t *testing.T) { + rule := genRule(testdataRule(testdataQueryModel, nil, nil)) + status, body := apiCli.SubmitRuleForTesting(t, rule) + require.Equal(t, http.StatusOK, status) + var result []amv2.PostableAlert + require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") + require.Len(t, result, 4) + for _, alert := range result { + require.Equal(t, rule.Rule.GrafanaManagedAlert.Title, alert.Labels[model.AlertNameLabel]) + require.Equal(t, rule.NamespaceUID, alert.Labels[alertingModels.NamespaceUIDLabel]) + require.Equal(t, rule.NamespaceTitle, alert.Labels[ngmodels.FolderTitleLabel]) + } + }) + + t.Run("invalid rule should reject request", func(t *testing.T) { + req := genRule(alertRuleGen()) + req.Rule = apimodels.PostableExtendedRuleNode{} + status, _ := apiCli.SubmitRuleForTesting(t, req) + require.Equal(t, http.StatusBadRequest, status) + }) + + t.Run("authentication permissions", func(t *testing.T) { + if !setting.IsEnterprise { + t.Skip("Enterprise-only test") + } + + testUserId := createUser(t, env.SQLStore, user.CreateUserCommand{ + DefaultOrgRole: "DOESNOTEXIST", // Needed so that the SignedInUser has OrgId=1. Otherwise, datasource will not be found. + Password: "test", + Login: "test", + }) + + testUserApiCli := newAlertingApiClient(grafanaListedAddr, "test", "test") + + t.Run("fail if can't read rules", func(t *testing.T) { + status, body := testUserApiCli.SubmitRuleForTesting(t, genRule(testdataRule(testdataQueryModel, nil, nil))) + require.Contains(t, body, accesscontrol.ActionAlertingRuleRead) + require.Equalf(t, http.StatusForbidden, status, "Response: %s", body) + }) + + // access control permissions store + permissionsStore := resourcepermissions.NewStore(env.SQLStore) + _, err := permissionsStore.SetUserResourcePermission(context.Background(), + accesscontrol.GlobalOrgID, + accesscontrol.User{ID: testUserId}, + resourcepermissions.SetResourcePermissionCommand{ + Actions: []string{ + accesscontrol.ActionAlertingRuleRead, + }, + Resource: "folders", + ResourceID: "*", + ResourceAttribute: "uid", + }, nil) + require.NoError(t, err) + testUserApiCli.ReloadCachedPermissions(t) + + t.Run("fail if can't query data sources", func(t *testing.T) { + status, body := testUserApiCli.SubmitRuleForTesting(t, genRule(testdataRule(testdataQueryModel, nil, nil))) + require.Contains(t, body, "user is not authorized to query one or many data sources used by the rule") + require.Equalf(t, http.StatusUnauthorized, status, "Response: %s", body) + }) + + _, err = permissionsStore.SetUserResourcePermission(context.Background(), + accesscontrol.GlobalOrgID, + accesscontrol.User{ID: testUserId}, + resourcepermissions.SetResourcePermissionCommand{ + Actions: []string{ + datasources.ActionQuery, + }, + Resource: "datasources", + ResourceID: TESTDATA_UID, + ResourceAttribute: "uid", + }, nil) + require.NoError(t, err) + testUserApiCli.ReloadCachedPermissions(t) + + t.Run("succeed if can query data sources", func(t *testing.T) { + status, body := testUserApiCli.SubmitRuleForTesting(t, genRule(testdataRule(testdataQueryModel, nil, nil))) + require.Equalf(t, http.StatusOK, status, "Response: %s", body) + }) + }) +} + +func testdataRule(queryModel json.RawMessage, labels map[string]string, annotations map[string]string) func() apimodels.PostableExtendedRuleNode { + return func() apimodels.PostableExtendedRuleNode { + forDuration := model.Duration(10 * time.Second) + return apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &forDuration, + Labels: labels, + Annotations: annotations, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: fmt.Sprintf("rule-%s", util.GenerateShortUID()), + Condition: "C", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{From: 600, To: 0}, + DatasourceUID: TESTDATA_UID, + Model: queryModel, + }, + { // Simple reduce last A. + RefID: "B", + RelativeTimeRange: apimodels.RelativeTimeRange{From: 0, To: 0}, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "refId": "B", + "hide": false, + "type": "reduce", + "datasource": { + "uid": "__expr__", + "type": "__expr__" + }, + "conditions": [ + { + "type": "query", + "evaluator": { + "params": [], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "B" + ] + }, + "reducer": { + "params": [], + "type": "last" + } + } + ], + "reducer": "last", + "expression": "A" + }`), + }, + { // Threshold B > 0. + RefID: "C", + RelativeTimeRange: apimodels.RelativeTimeRange{From: 0, To: 0}, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "refId": "C", + "hide": false, + "type": "threshold", + "datasource": { + "uid": "__expr__", + "type": "__expr__" + }, + "conditions": [ + { + "type": "query", + "evaluator": { + "params": [ + 0 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "C" + ] + }, + "reducer": { + "params": [], + "type": "last" + } + } + ], + "expression": "B" + }`), + }, + }, + }, + } + } +} diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index ee759178674..a06053eac08 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -334,3 +334,22 @@ func (a apiClient) SubmitRuleForBacktesting(t *testing.T, config apimodels.Backt require.NoError(t, err) return resp.StatusCode, string(b) } + +func (a apiClient) SubmitRuleForTesting(t *testing.T, config apimodels.PostableExtendedRuleNodeExtended) (int, string) { + t.Helper() + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + err := enc.Encode(config) + require.NoError(t, err) + + u := fmt.Sprintf("%s/api/v1/rule/test/grafana", a.url) + // nolint:gosec + resp, err := http.Post(u, "application/json", &buf) + require.NoError(t, err) + defer func() { + _ = resp.Body.Close() + }() + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return resp.StatusCode, string(b) +} From 6e3ff5c2656e1f19d5cca2967572bbebff75d81d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 9 Jun 2023 08:35:11 +0100 Subject: [PATCH 10/51] Update dependency css-loader to v6.8.1 (#69805) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 31 ++++++++++++++++++++++--------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index e9e2f89fca7..a53a4281e39 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "chance": "^1.0.10", "codeowners": "^5.1.1", "copy-webpack-plugin": "11.0.0", - "css-loader": "6.7.3", + "css-loader": "6.8.1", "css-minimizer-webpack-plugin": "4.2.2", "cypress": "9.5.1", "esbuild": "0.17.19", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index c1840a13a86..1d232481d82 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -165,7 +165,7 @@ "@types/tinycolor2": "1.4.3", "@types/uuid": "9.0.1", "common-tags": "1.8.2", - "css-loader": "6.7.3", + "css-loader": "6.8.1", "csstype": "3.1.1", "esbuild": "0.17.19", "expose-loader": "4.0.0", diff --git a/yarn.lock b/yarn.lock index 27f264c8080..ac5bf05dc0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3690,7 +3690,7 @@ __metadata: classnames: 2.3.2 common-tags: 1.8.2 core-js: 3.30.2 - css-loader: 6.7.3 + css-loader: 6.8.1 csstype: 3.1.1 d3: 7.8.2 date-fns: 2.29.3 @@ -14020,21 +14020,21 @@ __metadata: languageName: node linkType: hard -"css-loader@npm:6.7.3": - version: 6.7.3 - resolution: "css-loader@npm:6.7.3" +"css-loader@npm:6.8.1": + version: 6.8.1 + resolution: "css-loader@npm:6.8.1" dependencies: icss-utils: ^5.1.0 - postcss: ^8.4.19 + postcss: ^8.4.21 postcss-modules-extract-imports: ^3.0.0 - postcss-modules-local-by-default: ^4.0.0 + postcss-modules-local-by-default: ^4.0.3 postcss-modules-scope: ^3.0.0 postcss-modules-values: ^4.0.0 postcss-value-parser: ^4.2.0 semver: ^7.3.8 peerDependencies: webpack: ^5.0.0 - checksum: 473cc32b6c837c2848e2051ad1ba331c1457449f47442e75a8c480d9891451434ada241f7e3de2347e57de17fcd84610b3bcfc4a9da41102cdaedd1e17902d31 + checksum: 7c1784247bdbe76dc5c55fb1ac84f1d4177a74c47259942c9cfdb7a8e6baef11967a0bc85ac285f26bd26d5059decb848af8154a03fdb4f4894f41212f45eef3 languageName: node linkType: hard @@ -18415,7 +18415,7 @@ __metadata: common-tags: 1.8.2 copy-webpack-plugin: 11.0.0 core-js: 3.30.2 - css-loader: 6.7.3 + css-loader: 6.8.1 css-minimizer-webpack-plugin: 4.2.2 cypress: 9.5.1 d3: 7.8.2 @@ -24985,6 +24985,19 @@ __metadata: languageName: node linkType: hard +"postcss-modules-local-by-default@npm:^4.0.3": + version: 4.0.3 + resolution: "postcss-modules-local-by-default@npm:4.0.3" + dependencies: + icss-utils: ^5.0.0 + postcss-selector-parser: ^6.0.2 + postcss-value-parser: ^4.1.0 + peerDependencies: + postcss: ^8.1.0 + checksum: 2f8083687f3d6067885f8863dd32dbbb4f779cfcc7e52c17abede9311d84faf6d3ed8760e7c54c6380281732ae1f78e5e56a28baf3c271b33f450a11c9e30485 + languageName: node + linkType: hard + "postcss-modules-scope@npm:^3.0.0": version: 3.0.0 resolution: "postcss-modules-scope@npm:3.0.0" @@ -25263,7 +25276,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:8.4.21, postcss@npm:^8.4.19, postcss@npm:^8.4.21": +"postcss@npm:8.4.21, postcss@npm:^8.4.21": version: 8.4.21 resolution: "postcss@npm:8.4.21" dependencies: From 5d11def03320b2a4ff2a6c34755c6105bce5bdc6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 9 Jun 2023 10:52:09 +0300 Subject: [PATCH 11/51] Update dependency @babel/preset-typescript to v7.22.5 (#69832) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 316 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 307 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index a53a4281e39..97c6ea09e58 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "@babel/plugin-transform-typescript": "7.22.3", "@babel/preset-env": "7.22.4", "@babel/preset-react": "7.22.3", - "@babel/preset-typescript": "7.21.5", + "@babel/preset-typescript": "7.22.5", "@babel/runtime": "7.22.3", "@betterer/betterer": "5.4.0", "@betterer/cli": "5.4.0", diff --git a/yarn.lock b/yarn.lock index ac5bf05dc0b..1cc8f33f222 100644 --- a/yarn.lock +++ b/yarn.lock @@ -42,6 +42,15 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/code-frame@npm:7.22.5" + dependencies: + "@babel/highlight": ^7.22.5 + checksum: cfe804f518f53faaf9a1d3e0f9f74127ab9a004912c3a16fda07fb6a633393ecb9918a053cb71804204c1b7ec3d49e1699604715e2cfb0c9f7bc4933d324ebb6 + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.17.7, @babel/compat-data@npm:^7.20.5, @babel/compat-data@npm:^7.21.4, @babel/compat-data@npm:^7.22.0, @babel/compat-data@npm:^7.22.3": version: 7.22.3 resolution: "@babel/compat-data@npm:7.22.3" @@ -119,6 +128,18 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/generator@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + "@jridgewell/gen-mapping": ^0.3.2 + "@jridgewell/trace-mapping": ^0.3.17 + jsesc: ^2.5.1 + checksum: efa64da70ca88fe69f05520cf5feed6eba6d30a85d32237671488cc355fdc379fe2c3246382a861d49574c4c2f82a317584f8811e95eb024e365faff3232b49d + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-annotate-as-pure@npm:7.18.6" @@ -128,6 +149,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-annotate-as-pure@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-annotate-as-pure@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: 53da330f1835c46f26b7bf4da31f7a496dee9fd8696cca12366b94ba19d97421ce519a74a837f687749318f94d1a37f8d1abcbf35e8ed22c32d16373b2f6198d + languageName: node + linkType: hard + "@babel/helper-builder-binary-assignment-operator-visitor@npm:^7.18.6": version: 7.18.9 resolution: "@babel/helper-builder-binary-assignment-operator-visitor@npm:7.18.9" @@ -172,6 +202,25 @@ __metadata: languageName: node linkType: hard +"@babel/helper-create-class-features-plugin@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-create-class-features-plugin@npm:7.22.5" + dependencies: + "@babel/helper-annotate-as-pure": ^7.22.5 + "@babel/helper-environment-visitor": ^7.22.5 + "@babel/helper-function-name": ^7.22.5 + "@babel/helper-member-expression-to-functions": ^7.22.5 + "@babel/helper-optimise-call-expression": ^7.22.5 + "@babel/helper-replace-supers": ^7.22.5 + "@babel/helper-skip-transparent-expression-wrappers": ^7.22.5 + "@babel/helper-split-export-declaration": ^7.22.5 + semver: ^6.3.0 + peerDependencies: + "@babel/core": ^7.0.0 + checksum: f1e91deae06dbee6dd956c0346bca600adfbc7955427795d9d8825f0439a3c3290c789ba2b4a02a1cdf6c1a1bd163dfa16d3d5e96b02a8efb639d2a774e88ed9 + languageName: node + linkType: hard + "@babel/helper-create-regexp-features-plugin@npm:^7.18.6, @babel/helper-create-regexp-features-plugin@npm:^7.22.1": version: 7.22.1 resolution: "@babel/helper-create-regexp-features-plugin@npm:7.22.1" @@ -208,6 +257,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-environment-visitor@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-environment-visitor@npm:7.22.5" + checksum: 248532077d732a34cd0844eb7b078ff917c3a8ec81a7f133593f71a860a582f05b60f818dc5049c2212e5baa12289c27889a4b81d56ef409b4863db49646c4b1 + languageName: node + linkType: hard + "@babel/helper-explode-assignable-expression@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-explode-assignable-expression@npm:7.18.6" @@ -227,6 +283,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-function-name@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-function-name@npm:7.22.5" + dependencies: + "@babel/template": ^7.22.5 + "@babel/types": ^7.22.5 + checksum: 6b1f6ce1b1f4e513bf2c8385a557ea0dd7fa37971b9002ad19268ca4384bbe90c09681fe4c076013f33deabc63a53b341ed91e792de741b4b35e01c00238177a + languageName: node + linkType: hard + "@babel/helper-hoist-variables@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-hoist-variables@npm:7.18.6" @@ -236,6 +302,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-hoist-variables@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-hoist-variables@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: 394ca191b4ac908a76e7c50ab52102669efe3a1c277033e49467913c7ed6f7c64d7eacbeabf3bed39ea1f41731e22993f763b1edce0f74ff8563fd1f380d92cc + languageName: node + linkType: hard + "@babel/helper-member-expression-to-functions@npm:^7.0.0, @babel/helper-member-expression-to-functions@npm:^7.22.0": version: 7.22.3 resolution: "@babel/helper-member-expression-to-functions@npm:7.22.3" @@ -245,6 +320,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-member-expression-to-functions@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-member-expression-to-functions@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: 4bd5791529c280c00743e8bdc669ef0d4cd1620d6e3d35e0d42b862f8262bc2364973e5968007f960780344c539a4b9cf92ab41f5b4f94560a9620f536de2a39 + languageName: node + linkType: hard + "@babel/helper-module-imports@npm:^7.0.0, @babel/helper-module-imports@npm:^7.12.13, @babel/helper-module-imports@npm:^7.16.7, @babel/helper-module-imports@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-module-imports@npm:7.18.6" @@ -263,6 +347,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-imports@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-module-imports@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: 9ac2b0404fa38b80bdf2653fbeaf8e8a43ccb41bd505f9741d820ed95d3c4e037c62a1bcdcb6c9527d7798d2e595924c4d025daed73283badc180ada2c9c49ad + languageName: node + linkType: hard + "@babel/helper-module-transforms@npm:^7.18.6, @babel/helper-module-transforms@npm:^7.20.11, @babel/helper-module-transforms@npm:^7.21.2, @babel/helper-module-transforms@npm:^7.21.5, @babel/helper-module-transforms@npm:^7.22.1": version: 7.22.1 resolution: "@babel/helper-module-transforms@npm:7.22.1" @@ -279,6 +372,22 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-transforms@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-module-transforms@npm:7.22.5" + dependencies: + "@babel/helper-environment-visitor": ^7.22.5 + "@babel/helper-module-imports": ^7.22.5 + "@babel/helper-simple-access": ^7.22.5 + "@babel/helper-split-export-declaration": ^7.22.5 + "@babel/helper-validator-identifier": ^7.22.5 + "@babel/template": ^7.22.5 + "@babel/traverse": ^7.22.5 + "@babel/types": ^7.22.5 + checksum: 8985dc0d971fd17c467e8b84fe0f50f3dd8610e33b6c86e5b3ca8e8859f9448bcc5c84e08a2a14285ef388351c0484797081c8f05a03770bf44fc27bf4900e68 + languageName: node + linkType: hard + "@babel/helper-optimise-call-expression@npm:^7.0.0, @babel/helper-optimise-call-expression@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-optimise-call-expression@npm:7.18.6" @@ -288,6 +397,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-optimise-call-expression@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-optimise-call-expression@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: c70ef6cc6b6ed32eeeec4482127e8be5451d0e5282d5495d5d569d39eb04d7f1d66ec99b327f45d1d5842a9ad8c22d48567e93fc502003a47de78d122e355f7c + languageName: node + linkType: hard + "@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.16.7, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.18.9, @babel/helper-plugin-utils@npm:^7.19.0, @babel/helper-plugin-utils@npm:^7.20.2, @babel/helper-plugin-utils@npm:^7.8.0, @babel/helper-plugin-utils@npm:^7.8.3": version: 7.20.2 resolution: "@babel/helper-plugin-utils@npm:7.20.2" @@ -302,6 +420,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-plugin-utils@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-plugin-utils@npm:7.22.5" + checksum: c0fc7227076b6041acd2f0e818145d2e8c41968cc52fb5ca70eed48e21b8fe6dd88a0a91cbddf4951e33647336eb5ae184747ca706817ca3bef5e9e905151ff5 + languageName: node + linkType: hard + "@babel/helper-remap-async-to-generator@npm:^7.18.9": version: 7.18.9 resolution: "@babel/helper-remap-async-to-generator@npm:7.18.9" @@ -330,6 +455,20 @@ __metadata: languageName: node linkType: hard +"@babel/helper-replace-supers@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-replace-supers@npm:7.22.5" + dependencies: + "@babel/helper-environment-visitor": ^7.22.5 + "@babel/helper-member-expression-to-functions": ^7.22.5 + "@babel/helper-optimise-call-expression": ^7.22.5 + "@babel/template": ^7.22.5 + "@babel/traverse": ^7.22.5 + "@babel/types": ^7.22.5 + checksum: af29deff6c6dc3fa2d1a517390716aa3f4d329855e8689f1d5c3cb07c1b898e614a5e175f1826bb58e9ff1480e6552885a71a9a0ba5161787aaafa2c79b216cc + languageName: node + linkType: hard + "@babel/helper-simple-access@npm:^7.20.2": version: 7.20.2 resolution: "@babel/helper-simple-access@npm:7.20.2" @@ -348,6 +487,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-simple-access@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-simple-access@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: fe9686714caf7d70aedb46c3cce090f8b915b206e09225f1e4dbc416786c2fdbbee40b38b23c268b7ccef749dd2db35f255338fb4f2444429874d900dede5ad2 + languageName: node + linkType: hard + "@babel/helper-skip-transparent-expression-wrappers@npm:^7.20.0": version: 7.20.0 resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.20.0" @@ -357,6 +505,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-skip-transparent-expression-wrappers@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: 1012ef2295eb12dc073f2b9edf3425661e9b8432a3387e62a8bc27c42963f1f216ab3124228015c748770b2257b4f1fda882ca8fa34c0bf485e929ae5bc45244 + languageName: node + linkType: hard + "@babel/helper-split-export-declaration@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-split-export-declaration@npm:7.18.6" @@ -366,6 +523,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-split-export-declaration@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-split-export-declaration@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: d10e05a02f49c1f7c578cea63d2ac55356501bbf58856d97ac9bfde4957faee21ae97c7f566aa309e38a256eef58b58e5b670a7f568b362c00e93dfffe072650 + languageName: node + linkType: hard + "@babel/helper-string-parser@npm:^7.19.4": version: 7.19.4 resolution: "@babel/helper-string-parser@npm:7.19.4" @@ -380,6 +546,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-string-parser@npm:7.22.5" + checksum: 836851ca5ec813077bbb303acc992d75a360267aa3b5de7134d220411c852a6f17de7c0d0b8c8dcc0f567f67874c00f4528672b2a4f1bc978a3ada64c8c78467 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.18.6, @babel/helper-validator-identifier@npm:^7.19.1": version: 7.19.1 resolution: "@babel/helper-validator-identifier@npm:7.19.1" @@ -387,6 +560,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-validator-identifier@npm:7.22.5" + checksum: 7f0f30113474a28298c12161763b49de5018732290ca4de13cdaefd4fd0d635a6fe3f6686c37a02905fb1e64f21a5ee2b55140cf7b070e729f1bd66866506aea + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.21.0": version: 7.21.0 resolution: "@babel/helper-validator-option@npm:7.21.0" @@ -394,6 +574,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-option@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-validator-option@npm:7.22.5" + checksum: bbeca8a85ee86990215c0424997438b388b8d642d69b9f86c375a174d3cdeb270efafd1ff128bc7a1d370923d13b6e45829ba8581c027620e83e3a80c5c414b3 + languageName: node + linkType: hard + "@babel/helper-wrap-function@npm:^7.18.9": version: 7.20.5 resolution: "@babel/helper-wrap-function@npm:7.20.5" @@ -428,6 +615,17 @@ __metadata: languageName: node linkType: hard +"@babel/highlight@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/highlight@npm:7.22.5" + dependencies: + "@babel/helper-validator-identifier": ^7.22.5 + chalk: ^2.0.0 + js-tokens: ^4.0.0 + checksum: f61ae6de6ee0ea8d9b5bcf2a532faec5ab0a1dc0f7c640e5047fc61630a0edb88b18d8c92eb06566d30da7a27db841aca11820ecd3ebe9ce514c9350fbed39c4 + languageName: node + linkType: hard + "@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.21.9, @babel/parser@npm:^7.22.0, @babel/parser@npm:^7.22.4": version: 7.22.4 resolution: "@babel/parser@npm:7.22.4" @@ -446,6 +644,15 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/parser@npm:7.22.5" + bin: + parser: ./bin/babel-parser.js + checksum: 470ebba516417ce8683b36e2eddd56dcfecb32c54b9bb507e28eb76b30d1c3e618fd0cfeee1f64d8357c2254514e1a19e32885cfb4e73149f4ae875436a6d59c + languageName: node + linkType: hard + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:^7.18.6": version: 7.18.6 resolution: "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:7.18.6" @@ -802,6 +1009,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-jsx@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/plugin-syntax-jsx@npm:7.22.5" + dependencies: + "@babel/helper-plugin-utils": ^7.22.5 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 8829d30c2617ab31393d99cec2978e41f014f4ac6f01a1cecf4c4dd8320c3ec12fdc3ce121126b2d8d32f6887e99ca1a0bad53dedb1e6ad165640b92b24980ce + languageName: node + linkType: hard + "@babel/plugin-syntax-logical-assignment-operators@npm:^7.10.4, @babel/plugin-syntax-logical-assignment-operators@npm:^7.8.3": version: 7.10.4 resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4" @@ -901,6 +1119,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-typescript@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/plugin-syntax-typescript@npm:7.22.5" + dependencies: + "@babel/helper-plugin-utils": ^7.22.5 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 8ab7718fbb026d64da93681a57797d60326097fd7cb930380c8bffd9eb101689e90142c760a14b51e8e69c88a73ba3da956cb4520a3b0c65743aee5c71ef360a + languageName: node + linkType: hard + "@babel/plugin-syntax-unicode-sets-regex@npm:^7.18.6": version: 7.18.6 resolution: "@babel/plugin-syntax-unicode-sets-regex@npm:7.18.6" @@ -1253,6 +1482,19 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-modules-commonjs@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/plugin-transform-modules-commonjs@npm:7.22.5" + dependencies: + "@babel/helper-module-transforms": ^7.22.5 + "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-simple-access": ^7.22.5 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 2067aca8f6454d54ffcce69b02c457cfa61428e11372f6a1d99ff4fcfbb55c396ed2ca6ca886bf06c852e38c1a205b8095921b2364fd0243f3e66bc1dda61caa + languageName: node + linkType: hard + "@babel/plugin-transform-modules-systemjs@npm:^7.20.11, @babel/plugin-transform-modules-systemjs@npm:^7.22.3": version: 7.22.3 resolution: "@babel/plugin-transform-modules-systemjs@npm:7.22.3" @@ -1607,6 +1849,20 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-typescript@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/plugin-transform-typescript@npm:7.22.5" + dependencies: + "@babel/helper-annotate-as-pure": ^7.22.5 + "@babel/helper-create-class-features-plugin": ^7.22.5 + "@babel/helper-plugin-utils": ^7.22.5 + "@babel/plugin-syntax-typescript": ^7.22.5 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: d12f1ca1ef1f2a54432eb044d2999705d1205ebe211c2a7f05b12e8eb2d2a461fd7657b5486b2f2f1efe7c0c0dc8e80725b767073d40fe4ae059a7af057b05e4 + languageName: node + linkType: hard + "@babel/plugin-transform-unicode-escapes@npm:^7.18.10": version: 7.18.10 resolution: "@babel/plugin-transform-unicode-escapes@npm:7.18.10" @@ -1894,18 +2150,18 @@ __metadata: languageName: node linkType: hard -"@babel/preset-typescript@npm:7.21.5": - version: 7.21.5 - resolution: "@babel/preset-typescript@npm:7.21.5" +"@babel/preset-typescript@npm:7.22.5": + version: 7.22.5 + resolution: "@babel/preset-typescript@npm:7.22.5" dependencies: - "@babel/helper-plugin-utils": ^7.21.5 - "@babel/helper-validator-option": ^7.21.0 - "@babel/plugin-syntax-jsx": ^7.21.4 - "@babel/plugin-transform-modules-commonjs": ^7.21.5 - "@babel/plugin-transform-typescript": ^7.21.3 + "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-validator-option": ^7.22.5 + "@babel/plugin-syntax-jsx": ^7.22.5 + "@babel/plugin-transform-modules-commonjs": ^7.22.5 + "@babel/plugin-transform-typescript": ^7.22.5 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: e7b35c435139eec1d6bd9f57e8f3eb79bfc2da2c57a34ad9e9ea848ba4ecd72791cf4102df456604ab07c7f4518525b0764754b6dd5898036608b351e0792448 + checksum: 7be1670cb4404797d3a473bd72d66eb2b3e0f2f8a672a5e40bdb0812cc66085ec84bcd7b896709764cabf042fdc6b7f2d4755ac7cce10515eb596ff61dab5154 languageName: node linkType: hard @@ -1976,6 +2232,17 @@ __metadata: languageName: node linkType: hard +"@babel/template@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/template@npm:7.22.5" + dependencies: + "@babel/code-frame": ^7.22.5 + "@babel/parser": ^7.22.5 + "@babel/types": ^7.22.5 + checksum: c5746410164039aca61829cdb42e9a55410f43cace6f51ca443313f3d0bdfa9a5a330d0b0df73dc17ef885c72104234ae05efede37c1cc8a72dc9f93425977a3 + languageName: node + linkType: hard + "@babel/traverse@npm:^7.1.6, @babel/traverse@npm:^7.21.4, @babel/traverse@npm:~7.21.2": version: 7.21.4 resolution: "@babel/traverse@npm:7.21.4" @@ -2012,6 +2279,24 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/traverse@npm:7.22.5" + dependencies: + "@babel/code-frame": ^7.22.5 + "@babel/generator": ^7.22.5 + "@babel/helper-environment-visitor": ^7.22.5 + "@babel/helper-function-name": ^7.22.5 + "@babel/helper-hoist-variables": ^7.22.5 + "@babel/helper-split-export-declaration": ^7.22.5 + "@babel/parser": ^7.22.5 + "@babel/types": ^7.22.5 + debug: ^4.1.0 + globals: ^11.1.0 + checksum: 560931422dc1761f2df723778dcb4e51ce0d02e560cf2caa49822921578f49189a5a7d053b78a32dca33e59be886a6b2200a6e24d4ae9b5086ca0ba803815694 + languageName: node + linkType: hard + "@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.6, @babel/types@npm:^7.18.9, @babel/types@npm:^7.2.0, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.2, @babel/types@npm:^7.20.5, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.0, @babel/types@npm:^7.21.5, @babel/types@npm:^7.22.0, @babel/types@npm:^7.22.3, @babel/types@npm:^7.22.4, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3": version: 7.22.4 resolution: "@babel/types@npm:7.22.4" @@ -2034,6 +2319,17 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/types@npm:7.22.5" + dependencies: + "@babel/helper-string-parser": ^7.22.5 + "@babel/helper-validator-identifier": ^7.22.5 + to-fast-properties: ^2.0.0 + checksum: c13a9c1dc7d2d1a241a2f8363540cb9af1d66e978e8984b400a20c4f38ba38ca29f06e26a0f2d49a70bad9e57615dac09c35accfddf1bb90d23cd3e0a0bab892 + languageName: node + linkType: hard + "@base2/pretty-print-object@npm:1.0.1": version: 1.0.1 resolution: "@base2/pretty-print-object@npm:1.0.1" @@ -18261,7 +18557,7 @@ __metadata: "@babel/plugin-transform-typescript": 7.22.3 "@babel/preset-env": 7.22.4 "@babel/preset-react": 7.22.3 - "@babel/preset-typescript": 7.21.5 + "@babel/preset-typescript": 7.22.5 "@babel/runtime": 7.22.3 "@betterer/betterer": 5.4.0 "@betterer/cli": 5.4.0 From 417f6ceeb62e81e905b05427cbbd7c369309b3e9 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Fri, 9 Jun 2023 10:54:06 +0200 Subject: [PATCH 12/51] Grafana/ui: Fix margin in RadioButtonGroup option when only icon is present (#68899) --- .../src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx index 20e430a526c..2454124a82e 100644 --- a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx +++ b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx @@ -75,6 +75,7 @@ export function RadioButtonGroup({ {options.map((opt, i) => { const isItemDisabled = disabledOptions && opt.value && disabledOptions.includes(opt.value); const icon = opt.icon ? toIconName(opt.icon) : undefined; + const hasNonIconPart = Boolean(opt.imgUrl || opt.label || opt.component); return ( ({ fullWidth={fullWidth} ref={value === opt.value ? activeButtonRef : undefined} > - {icon && } + {icon && } {opt.imgUrl && {opt.label}} {opt.label} {opt.component ? : null} From 5a2e66676eb1bd284024680f6065a66b45f25c62 Mon Sep 17 00:00:00 2001 From: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Fri, 9 Jun 2023 10:58:05 +0200 Subject: [PATCH 13/51] Docs:fixes alerting support escalations (#69770) * Docs:fixes alerting support escalations * updates recording rule text * adds link * fixes link * Update docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Update docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fixes relref * adds link to prometheus docs * fixes relref --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- ...reate-mimir-loki-managed-recording-rule.md | 28 ++++++------------- .../manage-notifications/create-silence.md | 4 +++ .../create-notification-templates.md | 8 +++++- .../http_api/alerting_provisioning.md | 4 +++ 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md b/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md index 604222bf681..425e2d8d6db 100644 --- a/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md +++ b/docs/sources/alerting/alerting-rules/create-mimir-loki-managed-recording-rule.md @@ -18,6 +18,14 @@ weight: 400 You can create and manage recording rules for an external Grafana Mimir or Loki instance. Recording rules calculate frequently needed expressions or computationally expensive expressions in advance and save the result as a new set of time series. Querying this new time series is faster, especially for dashboards since they query the same expression every time the dashboards refresh. +**Note:** + +Recording rules are run as instant rules, which means that they run every 10s. To overwrite this configuration, update the min_interval in your custom configuration file. + +[min_interval]({{< relref "../../setup-grafana/configure-grafana" >}}) sets the minimum interval to enforce between rule evaluations. The default value is 10s which equals the scheduler interval. Rules will be adjusted if they are less than this value or if they are not multiple of the scheduler interval (10s). Higher values can help with resource management as fewer evaluations are scheduled over time. + +This setting has precedence over each individual rule frequency. If a rule frequency is lower than this value, then this value is enforced. + ## Before you begin - Verify that you have write permission to the Prometheus or Loki data source. Otherwise, you will not be able to create or update Grafana Mimir managed alerting rules. @@ -44,25 +52,7 @@ To create a Grafana Mimir or Loki managed recording rule 1. In Step 2, select **Mimir or Loki recording rule** option. - Select your Loki or Prometheus data source. - Enter a PromQL or LogQL query. -1. In Step 3, add the namespace and the group. - - From the **Namespace** dropdown, select an existing rule namespace. Otherwise, click Add new and enter a name to create a new one. Namespaces can contain one or more rule groups and only have an organizational purpose. For more information, see [Grafana Mimir or Loki rule groups and namespaces]({{< relref "./edit-mimir-loki-namespace-group" >}}). - - From the **Group** dropdown, select an existing group within the selected namespace. Otherwise, click **Add new** and enter a name to create a new one. -1. In Step 4, add the custom labels. - - Add custom labels selecting existing key-value pairs from the drop down, or add new labels by entering the new key or value. -1. Click **Save** to save the recording rule or **Save and exit** to save the recording rule and go back to the Alerting page. - - - -1. In the left-side menu, click **Alerts & IRM** and then **Alerting**. -1. Click **Alert rules**. -1. Click **+ Create alert rule**. -1. In Step 1, add the rule name. - - In **Rule name**, add a descriptive name. This name is displayed in the alert rule list. It is also the `alertname` label for every alert instance that is created from this rule. -1. In Step 2, add the type, and storage location. - - From the **Rule type** dropdown, select **Mimir / Loki managed alert**. - - From the **Select data source** dropdown, select an external Prometheus, an external Loki, or a Grafana Cloud data source. - - Enter a PromQL or LogQL expression. The rule fires if the evaluation result has at least one series with a value that is greater than 0. An alert is created for each series. -1. In Step 3, add evaluation behavior. +1. In Step 3, add alert evaluation behavior. - Enter a valid **For** duration. The expression has to be true for this long for the alert to be fired. 1. In Step 4, add additional metadata associated with the rule. - From the **Namespace** dropdown, select an existing rule namespace. Otherwise, click Add new and enter a name to create a new one. Namespaces can contain one or more rule groups and only have an organizational purpose. For more information, see [Grafana Mimir or Loki rule groups and namespaces]({{< relref "./edit-mimir-loki-namespace-group" >}}). diff --git a/docs/sources/alerting/manage-notifications/create-silence.md b/docs/sources/alerting/manage-notifications/create-silence.md index 8553a96dbd7..e2550436fc3 100644 --- a/docs/sources/alerting/manage-notifications/create-silence.md +++ b/docs/sources/alerting/manage-notifications/create-silence.md @@ -61,3 +61,7 @@ To remove a silence, complete the following steps. 1. Select the silence you want to end, then click **Unsilence**. > **Note:** You cannot remove a silence manually. Silences that have ended are retained and listed for five days. + +## Useful links + +[Aggregation operators](/docs/prometheus/latest/querying/operators/#aggregation-operators) diff --git a/docs/sources/alerting/manage-notifications/template-notifications/create-notification-templates.md b/docs/sources/alerting/manage-notifications/template-notifications/create-notification-templates.md index 525206b5643..138e782c253 100644 --- a/docs/sources/alerting/manage-notifications/template-notifications/create-notification-templates.md +++ b/docs/sources/alerting/manage-notifications/template-notifications/create-notification-templates.md @@ -179,7 +179,13 @@ Template the title of a Slack message to contain the number of firing and resolv ## Template the content of a Slack message -Template the content of a Slack message to contain a description of all firing and resolved alerts, including their labels, annotations, Silence URL and Dashboard URL: +Template the content of a Slack message to contain a description of all firing and resolved alerts, including their labels, annotations, Silence URL and Dashboard URL. + +**Note:** + +This template is for Grafana-managed alerts only. +To use the template for Grafana Mimir/Loki-managed alerts, delete the references to DashboardURL and SilenceURL. +For more information, see the [Prometheus documentation on notifications](https://prometheus.io/docs/alerting/latest/notifications/). ``` 1 firing alert(s): diff --git a/docs/sources/developers/http_api/alerting_provisioning.md b/docs/sources/developers/http_api/alerting_provisioning.md index 6eb756452dc..491757a567b 100644 --- a/docs/sources/developers/http_api/alerting_provisioning.md +++ b/docs/sources/developers/http_api/alerting_provisioning.md @@ -52,6 +52,10 @@ title: 'Alerting Provisioning HTTP API ' ### Contact points +**Note:** + +Contact point provisioning is for Grafana-managed alerts only. + | Method | URI | Name | Summary | | ------ | ----------------------------------------- | --------------------------------------------------------- | --------------------------------- | | DELETE | /api/v1/provisioning/contact-points/{UID} | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. | From 1696bc201eeac25f28184bfa47bb0960c1c90b73 Mon Sep 17 00:00:00 2001 From: Domas Date: Fri, 9 Jun 2023 12:17:05 +0300 Subject: [PATCH 14/51] Tempo/ServiceGraph: Specify explicit field types (#69759) Assign field types to service graph fields --- .../datasource/tempo/graphTransform.test.ts | 35 ++++++++++++++++++- .../datasource/tempo/graphTransform.ts | 24 ++++++++----- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/public/app/plugins/datasource/tempo/graphTransform.test.ts b/public/app/plugins/datasource/tempo/graphTransform.test.ts index c5e8f9f049b..a81f776de51 100644 --- a/public/app/plugins/datasource/tempo/graphTransform.test.ts +++ b/public/app/plugins/datasource/tempo/graphTransform.test.ts @@ -1,4 +1,4 @@ -import { DataFrameView, dateTime, createDataFrame } from '@grafana/data'; +import { DataFrameView, dateTime, createDataFrame, FieldType } from '@grafana/data'; import { createGraphFrames, mapPromMetricsToServiceMap } from './graphTransform'; import { bigResponse } from './testResponse'; @@ -59,6 +59,26 @@ describe('createGraphFrames', () => { }); }); +it('assigns correct field type even if values are numbers', async () => { + const range = { + from: dateTime('2000-01-01T00:00:00'), + to: dateTime('2000-01-01T00:01:00'), + }; + const { nodes } = mapPromMetricsToServiceMap([{ data: [serverIsANumber, serverIsANumber] }], { + ...range, + raw: range, + }); + + expect(nodes.fields).toMatchObject([ + { name: 'id', values: ['0', '1'], type: FieldType.string }, + { name: 'title', values: ['0', '1'], type: FieldType.string }, + { name: 'mainstat', values: [NaN, NaN], type: FieldType.number }, + { name: 'secondarystat', values: [10, 20], type: FieldType.number }, + { name: 'arc__success', values: [1, 1], type: FieldType.number }, + { name: 'arc__failed', values: [0, 0], type: FieldType.number }, + ]); +}); + describe('mapPromMetricsToServiceMap', () => { it('transforms prom metrics to service graph', async () => { const range = { @@ -191,3 +211,16 @@ const invalidFailedPromMetric = createDataFrame({ { name: 'Value #traces_service_graph_request_failed_total', values: [20, 40] }, ], }); + +const serverIsANumber = createDataFrame({ + refId: 'traces_service_graph_request_total', + fields: [ + { name: 'Time', values: [1628169788000, 1628169788000] }, + { name: 'client', values: ['0', '1'] }, + { name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] }, + { name: 'job', values: ['local_scrape', 'local_scrape'] }, + { name: 'server', values: ['0', '1'] }, + { name: 'tempo_config', values: ['default', 'default'] }, + { name: 'Value #traces_service_graph_request_total', values: [10, 20] }, + ], +}); diff --git a/public/app/plugins/datasource/tempo/graphTransform.ts b/public/app/plugins/datasource/tempo/graphTransform.ts index 292d059876f..eb36c6f8737 100644 --- a/public/app/plugins/datasource/tempo/graphTransform.ts +++ b/public/app/plugins/datasource/tempo/graphTransform.ts @@ -7,6 +7,7 @@ import { MutableDataFrame, NodeGraphDataFrameFieldNames as Fields, TimeRange, + FieldType, } from '@grafana/data'; import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '../../../core/utils/tracing'; @@ -188,28 +189,35 @@ function createServiceMapDataFrames() { } const nodes = createDF('Nodes', [ - { name: Fields.id }, - { name: Fields.title, config: { displayName: 'Service name' } }, - { name: Fields.mainStat, config: { unit: 'ms/r', displayName: 'Average response time' } }, + { name: Fields.id, type: FieldType.string }, + { name: Fields.title, type: FieldType.string, config: { displayName: 'Service name' } }, + { name: Fields.mainStat, type: FieldType.number, config: { unit: 'ms/r', displayName: 'Average response time' } }, { name: Fields.secondaryStat, + type: FieldType.number, config: { unit: 'r/sec', displayName: 'Requests per second' }, }, { name: Fields.arc + 'success', + type: FieldType.number, config: { displayName: 'Success', color: { fixedColor: 'green', mode: FieldColorModeId.Fixed } }, }, { name: Fields.arc + 'failed', + type: FieldType.number, config: { displayName: 'Failed', color: { fixedColor: 'red', mode: FieldColorModeId.Fixed } }, }, ]); const edges = createDF('Edges', [ - { name: Fields.id }, - { name: Fields.source }, - { name: Fields.target }, - { name: Fields.mainStat, config: { unit: 'ms/r', displayName: 'Average response time' } }, - { name: Fields.secondaryStat, config: { unit: 'r/sec', displayName: 'Requests per second' } }, + { name: Fields.id, type: FieldType.string }, + { name: Fields.source, type: FieldType.string }, + { name: Fields.target, type: FieldType.string }, + { name: Fields.mainStat, type: FieldType.number, config: { unit: 'ms/r', displayName: 'Average response time' } }, + { + name: Fields.secondaryStat, + type: FieldType.number, + config: { unit: 'r/sec', displayName: 'Requests per second' }, + }, ]); return [nodes, edges]; From 3dc8255639c8afe80ec1c1597b2d48293724ee9b Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Fri, 9 Jun 2023 10:54:51 +0100 Subject: [PATCH 15/51] Changelog: Updated changelog for 10.0.0-preview (#69837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Changelog: Updated changelog for 10.0.0-preview * prettier --------- Co-authored-by: Torkel Ödegaard --- CHANGELOG.md | 63 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 379d4f1e7cd..c475d65bdce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ### Features and enhancements +- **Alerting:** Migrate unknown NoData\Error settings to the default. [#69010](https://github.com/grafana/grafana/issues/69010), [@grafanabot](https://github.com/grafanabot) +- **Drawer:** Position under nav & minor redesign . [#68396](https://github.com/grafana/grafana/issues/68396), [@grafanabot](https://github.com/grafanabot) +- **Navigation:** Add keyboard shortcut to navigate directly to Dashboards. [#68374](https://github.com/grafana/grafana/issues/68374), [@grafanabot](https://github.com/grafanabot) +- **Explore:** Promote exploreMixedDatasource to Stable & enable by default. [#68353](https://github.com/grafana/grafana/issues/68353), [@Elfo404](https://github.com/Elfo404) +- **Tempo:** Escape regex-sensitive characters in span name before building promql query. [#68313](https://github.com/grafana/grafana/issues/68313), [@grafanabot](https://github.com/grafanabot) +- **Drawer:** Introduce a size property that set's width percentage and minWidth . [#68128](https://github.com/grafana/grafana/issues/68128), [@grafanabot](https://github.com/grafanabot) +- **AngularDeprecation:** Show warnings in panel edit for angular panels. [#68083](https://github.com/grafana/grafana/issues/68083), [@grafanabot](https://github.com/grafanabot) +- **Dashboard:** Change add panel button to fill to remove outline border. [#68017](https://github.com/grafana/grafana/issues/68017), [@grafanabot](https://github.com/grafanabot) - **Query History:** Remove migration. [#67470](https://github.com/grafana/grafana/issues/67470), [@Elfo404](https://github.com/Elfo404) - **Alerting:** Implement template testing endpoint. [#67450](https://github.com/grafana/grafana/issues/67450), [@JacobsonMT](https://github.com/JacobsonMT) - **Trace View:** Export trace button . [#67368](https://github.com/grafana/grafana/issues/67368), [@adrapereira](https://github.com/adrapereira) @@ -100,6 +108,37 @@ ### Bug fixes +- **ResourcePicker:** Fix missing border bug on cancel button. [#69113](https://github.com/grafana/grafana/issues/69113), [@nmarrs](https://github.com/nmarrs) +- **TimeSeries:** Fix centeredZero y axis ranging when all values are 0. [#69112](https://github.com/grafana/grafana/issues/69112), [@grafanabot](https://github.com/grafanabot) +- **StatusHistory:** Fix rendering of value-mapped null. [#69108](https://github.com/grafana/grafana/issues/69108), [@grafanabot](https://github.com/grafanabot) +- **Alerting:** Fix provenance guard checks for Alertmanager configuration to not cause panic when compared nested objects. [#69094](https://github.com/grafana/grafana/issues/69094), [@grafanabot](https://github.com/grafanabot) +- **Alerting:** Add support for Alert State History Loki primary. [#69077](https://github.com/grafana/grafana/issues/69077), [@grafanabot](https://github.com/grafanabot) +- **Dashboards:** Fix undefined aria labels in Annotation Checkboxes for Programmatic Access. [#68873](https://github.com/grafana/grafana/issues/68873), [@grafanabot](https://github.com/grafanabot) +- **Alerting:** Fix stale query preview error. [#68836](https://github.com/grafana/grafana/issues/68836), [@grafanabot](https://github.com/grafanabot) +- **AnonymousAuth:** Fix concurrent read-write crash. [#68803](https://github.com/grafana/grafana/issues/68803), [@grafanabot](https://github.com/grafanabot) +- **AzureMonitor:** Ensure legacy properties containing template variables are correctly migrated. [#68792](https://github.com/grafana/grafana/issues/68792), [@grafanabot](https://github.com/grafanabot) +- **Alerting:** Add additional contact points for external AM. [#68778](https://github.com/grafana/grafana/issues/68778), [@grafanabot](https://github.com/grafanabot) +- **RBAC:** Remove legacy AC editor and admin role on new dashboard route. [#68777](https://github.com/grafana/grafana/issues/68777), [@grafanabot](https://github.com/grafanabot) +- **Azure Monitor:** Fix bug with top value so more than 10 resources can be shown . [#68725](https://github.com/grafana/grafana/issues/68725), [@grafanabot](https://github.com/grafanabot) +- **NodeGraph:** Fix overlaps preventing opening an edge context menu when nodes were too close. [#68628](https://github.com/grafana/grafana/issues/68628), [@grafanabot](https://github.com/grafanabot) +- **Plugins:** Correct the usage of mutex for gRPC plugin implementation. [#68609](https://github.com/grafana/grafana/issues/68609), [@grafanabot](https://github.com/grafanabot) +- **Azure Monitor:** Fix bug that did not show alert rule preview. [#68581](https://github.com/grafana/grafana/issues/68581), [@grafanabot](https://github.com/grafanabot) +- **FlameGraph:** Fix table sort being reset when search changes. [#68454](https://github.com/grafana/grafana/issues/68454), [@grafanabot](https://github.com/grafanabot) +- **Command Palette:** Prevent stale search results from overwriting newer results. [#68392](https://github.com/grafana/grafana/issues/68392), [@grafanabot](https://github.com/grafanabot) +- **Search:** Fix Search returning results out of order. [#68387](https://github.com/grafana/grafana/issues/68387), [@joshhunt](https://github.com/joshhunt) +- **Explore:** Remove data source onboarding page. [#68381](https://github.com/grafana/grafana/issues/68381), [@grafanabot](https://github.com/grafanabot) +- **Flamegraph:** Fix tooltip positioning. [#68312](https://github.com/grafana/grafana/issues/68312), [@grafanabot](https://github.com/grafanabot) +- **Pyroscope:** Add authentication when calling backendType resource API. [#68311](https://github.com/grafana/grafana/issues/68311), [@grafanabot](https://github.com/grafanabot) +- **Histogram:** Respect min/max panel settings for x-axis. [#68245](https://github.com/grafana/grafana/issues/68245), [@grafanabot](https://github.com/grafanabot) +- **QueryRow:** Make toggle actions screen-readers accessible. [#68210](https://github.com/grafana/grafana/issues/68210), [@grafanabot](https://github.com/grafanabot) +- **Heatmap:** Fix color rendering for value ranges < 1. [#68164](https://github.com/grafana/grafana/issues/68164), [@grafanabot](https://github.com/grafanabot) +- **Heatmap:** Handle unsorted timestamps in calculate mode. [#68151](https://github.com/grafana/grafana/issues/68151), [@grafanabot](https://github.com/grafanabot) +- **Alerting:** Fixes Alert list panel "ungrouped" regression. [#68090](https://github.com/grafana/grafana/issues/68090), [@grafanabot](https://github.com/grafanabot) +- **Alerting:** Show export button for org admins. [#67995](https://github.com/grafana/grafana/issues/67995), [@grafanabot](https://github.com/grafanabot) +- **Navigation:** Fix 'Page not found' when sending or going back from 'Invitate user' page. [#67972](https://github.com/grafana/grafana/issues/67972), [@grafanabot](https://github.com/grafanabot) +- **InspectDrawer:** Fixes issue with double scrollbars. [#67888](https://github.com/grafana/grafana/issues/67888), [@grafanabot](https://github.com/grafanabot) +- **Connections:** Show core datasource plugins as well. [#67886](https://github.com/grafana/grafana/issues/67886), [@grafanabot](https://github.com/grafanabot) +- **Gauge:** Set min and max for percent unit. [#67719](https://github.com/grafana/grafana/issues/67719), [@grafanabot](https://github.com/grafanabot) - **TimeSeries:** Fix leading null-fill for missing intervals. [#67570](https://github.com/grafana/grafana/issues/67570), [@leeoniya](https://github.com/leeoniya) - **Pyroscope:** Fix autodetection in case of using Phlare backend. [#67536](https://github.com/grafana/grafana/issues/67536), [@aocenas](https://github.com/aocenas) - **Dashboard:** Revert fixed header shown on mobile devices in the new panel header. [#67510](https://github.com/grafana/grafana/issues/67510), [@axelavargas](https://github.com/axelavargas) @@ -181,13 +220,15 @@ The deprecated `plugin:test` and `plugin:dev` commands in the Grafana Toolkit ha The type signature of the `testDatasource()` method on the `DataSourceWithBackend` class [has changed](https://github.com/grafana/grafana/pull/67014/files/a5608dc4f27ab4459e725b22ff60b8fc05390c08#diff-c58fc1a09e9b9b17e5f45efbfb646273e69145f7687facb134440da4edafc745R263), the returned Promise is now typed stricter, which is probably going to cause type-errors while building plugins against the latest Grafana versions. -````typescript +```typescript // Before abstract testDatasource(): Promise; // After abstract testDatasource(): Promise; -``` Issue [#67014](https://github.com/grafana/grafana/issues/67014) +``` + +Issue [#67014](https://github.com/grafana/grafana/issues/67014) Grafana requires an Elasticsearch version of 7.16 or newer. If you use an older Elasticsearch version, you will get warnings in the query editor and on the datasource configuration page. Issue [#66928](https://github.com/grafana/grafana/issues/66928) @@ -200,19 +241,19 @@ We've removed some now unused properties from the `NavModel` interface. Issue [# We removed previously deprecated components from `@grafana/data` : `getLogLevel`, `getLogLevelFromKey`, `addLogLevelToSeries`, `LogsParser`, `LogsParsers`, `calculateFieldStats`, `calculateLogsLabelStats`, `calculateStats`, `getParser`, `sortInAscendingOrder`, `sortInDescendingOrder`, `sortLogsResult`, `sortLogRows`, `checkLogsError`, `escapeUnescapedString`. Issue [#66271](https://github.com/grafana/grafana/issues/66271) We removed previously deprecated components from `@grafana/ui` : `LogLabels`, `LogMessageAnsi`, `LogRows`, `getLogRowStyles`. - Issue [#66268](https://github.com/grafana/grafana/issues/66268) +Issue [#66268](https://github.com/grafana/grafana/issues/66268) -We removed previously deprecated `DataSourceWithLogsVolumeSupport` that was replaced with `DataSourceWithSupplementaryQueriesSupport`. Both APIs are for internal use only. Issue [#66266](https://github.com/grafana/grafana/issues/66266) +We removed previously deprecated `DataSourceWithLogsVolumeSupport` that was replaced with `DataSourceWithSupplementaryQueriesSupport`. Both APIs are for internal use only. Issue [#66266](https://github.com/grafana/grafana/issues/66266) -Additional functions (map/filter/forEach/iterator) have been added to the root Vector interface. Any code using vectors will continue to work unchanged, but in the rare case that you have implemented Vector directly, it be missing these functions. The easiest fix is to extend [FunctionalVector](https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/vector/FunctionalVector.ts). +Additional functions (map/filter/forEach/iterator) have been added to the root Vector interface. Any code using vectors will continue to work unchanged, but in the rare case that you have implemented Vector directly, it be missing these functions. The easiest fix is to extend [FunctionalVector](https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/vector/FunctionalVector.ts). The `ArrayVector` class now extends the native JavaScript `Array` and gains all of its prototype/instance methods as a result. Issue [#66187](https://github.com/grafana/grafana/issues/66187) We've removed the ability for functions to be passed as children to the `Dropdown` component. Previously, this was used to access the `isOpen` state of the dropdown. This can be now be achieved with the `onVisibleChange` prop. Before: -```` +``` return ( {(isOpen) => @@ -220,12 +261,11 @@ return ( } ); - ``` After: -``` +```` const [isOpen, setIsOpen] = useState(false); ... @@ -235,8 +275,7 @@ return ( ); - -````Issue [#65467](https://github.com/grafana/grafana/issues/65467) +``` Issue [#65467](https://github.com/grafana/grafana/issues/65467) (relevant for plugin developers) The deprecated internal `dashboardId` is now removed from the request context. For usage tracking use the `dashboardUid` @@ -3317,8 +3356,8 @@ The change in behavior is that negative-valued series are now stacked downwards The meaning of the default data source has now changed from being a persisted property in a panel. Before when you selected the default data source for a panel and later changed the default data source to another data source it would change all panels who were configured to use the default data source. From now on the default data source is just the default for new panels and changing the default will not impact any currently saved dashboards. Issue [#45132](https://github.com/grafana/grafana/issues/45132) -The Tooltip component provided by `@grafana/ui` is no longer automatically interactive (that is you can hover onto it and click a link or select text). It will from now on by default close automatically when you mouse out from the trigger element. To make tooltips behave like before set the new `interactive` property to true. - Issue [#45053](https://github.com/grafana/grafana/issues/45053) +The Tooltip component provided by `@grafana/ui` is no longer automatically interactive (that is you can hover onto it and click a link or select text). It will from now on by default close automatically when you mouse out from the trigger element. To make tooltips behave like before set the new `interactive` property to true. +Issue [#45053](https://github.com/grafana/grafana/issues/45053) ### Deprecations From 9f18e0ccf3531af837e10581d38fc5ea711af362 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Fri, 9 Jun 2023 12:08:26 +0200 Subject: [PATCH 16/51] Plugins: Re-use plugin registry mocks from fakes package (#69840) re-use mocks from fakes package --- pkg/plugins/manager/process/process_test.go | 63 ++++------- pkg/plugins/manager/store/store_test.go | 111 ++++++++------------ 2 files changed, 62 insertions(+), 112 deletions(-) diff --git a/pkg/plugins/manager/process/process_test.go b/pkg/plugins/manager/process/process_test.go index 275b743bd0c..1a1d6849a86 100644 --- a/pkg/plugins/manager/process/process_test.go +++ b/pkg/plugins/manager/process/process_test.go @@ -10,11 +10,12 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/manager/fakes" ) func TestProcessManager_Start(t *testing.T) { t.Run("Plugin not found in registry", func(t *testing.T) { - m := NewManager(newFakePluginRegistry(map[string]*plugins.Plugin{})) + m := NewManager(fakes.NewFakePluginRegistry()) err := m.Start(context.Background(), "non-existing-datasource") require.ErrorIs(t, err, backendplugin.ErrPluginNotRegistered) }) @@ -63,9 +64,11 @@ func TestProcessManager_Start(t *testing.T) { plugin.SignatureError = tc.signatureError }) - m := NewManager(newFakePluginRegistry(map[string]*plugins.Plugin{ - p.ID: p, - })) + m := NewManager(&fakes.FakePluginRegistry{ + Store: map[string]*plugins.Plugin{ + p.ID: p, + }}, + ) err := m.Start(context.Background(), p.ID) require.NoError(t, err) @@ -83,7 +86,7 @@ func TestProcessManager_Start(t *testing.T) { func TestProcessManager_Stop(t *testing.T) { t.Run("Plugin not found in registry", func(t *testing.T) { - m := NewManager(newFakePluginRegistry(map[string]*plugins.Plugin{})) + m := NewManager(fakes.NewFakePluginRegistry()) err := m.Stop(context.Background(), "non-existing-datasource") require.ErrorIs(t, err, backendplugin.ErrPluginNotRegistered) }) @@ -97,9 +100,11 @@ func TestProcessManager_Stop(t *testing.T) { plugin.Backend = true }) - m := NewManager(newFakePluginRegistry(map[string]*plugins.Plugin{ - pluginID: p, - })) + m := NewManager(&fakes.FakePluginRegistry{ + Store: map[string]*plugins.Plugin{ + pluginID: p, + }}, + ) err := m.Stop(context.Background(), pluginID) require.NoError(t, err) @@ -116,9 +121,11 @@ func TestProcessManager_ManagedBackendPluginLifecycle(t *testing.T) { plugin.Backend = true }) - m := NewManager(newFakePluginRegistry(map[string]*plugins.Plugin{ - p.ID: p, - })) + m := NewManager(&fakes.FakePluginRegistry{ + Store: map[string]*plugins.Plugin{ + p.ID: p, + }}, + ) err := m.Start(context.Background(), p.ID) require.NoError(t, err) @@ -162,40 +169,6 @@ func TestProcessManager_ManagedBackendPluginLifecycle(t *testing.T) { }) } -type fakePluginRegistry struct { - store map[string]*plugins.Plugin -} - -func newFakePluginRegistry(m map[string]*plugins.Plugin) *fakePluginRegistry { - return &fakePluginRegistry{ - store: m, - } -} - -func (f *fakePluginRegistry) Plugin(_ context.Context, id string) (*plugins.Plugin, bool) { - p, exists := f.store[id] - return p, exists -} - -func (f *fakePluginRegistry) Plugins(_ context.Context) []*plugins.Plugin { - var res []*plugins.Plugin - - for _, p := range f.store { - res = append(res, p) - } - return res -} - -func (f *fakePluginRegistry) Add(_ context.Context, p *plugins.Plugin) error { - f.store[p.ID] = p - return nil -} - -func (f *fakePluginRegistry) Remove(_ context.Context, id string) error { - delete(f.store, id) - return nil -} - type fakeBackendPlugin struct { managed bool diff --git a/pkg/plugins/manager/store/store_test.go b/pkg/plugins/manager/store/store_test.go index 6ff8bea4eaa..8772ca9824f 100644 --- a/pkg/plugins/manager/store/store_test.go +++ b/pkg/plugins/manager/store/store_test.go @@ -54,10 +54,12 @@ func TestStore_Plugin(t *testing.T) { p1.RegisterClient(&DecommissionedPlugin{}) p2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-panel"}} - ps := New(newFakePluginRegistry(map[string]*plugins.Plugin{ - p1.ID: p1, - p2.ID: p2, - })) + ps := New(&fakes.FakePluginRegistry{ + Store: map[string]*plugins.Plugin{ + p1.ID: p1, + p2.ID: p2, + }, + }) p, exists := ps.Plugin(context.Background(), p1.ID) require.False(t, exists) @@ -78,13 +80,15 @@ func TestStore_Plugins(t *testing.T) { p5 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "e-test-panel", Type: plugins.TypePanel}} p5.RegisterClient(&DecommissionedPlugin{}) - ps := New(newFakePluginRegistry(map[string]*plugins.Plugin{ - p1.ID: p1, - p2.ID: p2, - p3.ID: p3, - p4.ID: p4, - p5.ID: p5, - })) + ps := New(&fakes.FakePluginRegistry{ + Store: map[string]*plugins.Plugin{ + p1.ID: p1, + p2.ID: p2, + p3.ID: p3, + p4.ID: p4, + p5.ID: p5, + }, + }) pss := ps.Plugins(context.Background()) require.Equal(t, pss, []plugins.PluginDTO{p1.ToDTO(), p2.ToDTO(), p3.ToDTO(), p4.ToDTO()}) @@ -113,14 +117,16 @@ func TestStore_Routes(t *testing.T) { p6 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "f-test-app", Type: plugins.TypeApp}} p6.RegisterClient(&DecommissionedPlugin{}) - ps := New(newFakePluginRegistry(map[string]*plugins.Plugin{ - p1.ID: p1, - p2.ID: p2, - p3.ID: p3, - p4.ID: p4, - p5.ID: p5, - p6.ID: p6, - })) + ps := New(&fakes.FakePluginRegistry{ + Store: map[string]*plugins.Plugin{ + p1.ID: p1, + p2.ID: p2, + p3.ID: p3, + p4.ID: p4, + p5.ID: p5, + p6.ID: p6, + }, + }) sr := func(p *plugins.Plugin) *plugins.StaticRoute { return &plugins.StaticRoute{PluginID: p.ID, Directory: p.FS.Base()} @@ -137,11 +143,13 @@ func TestStore_Renderer(t *testing.T) { p2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-panel", Type: plugins.TypePanel}} p3 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-app", Type: plugins.TypeApp}} - ps := New(newFakePluginRegistry(map[string]*plugins.Plugin{ - p1.ID: p1, - p2.ID: p2, - p3.ID: p3, - })) + ps := New(&fakes.FakePluginRegistry{ + Store: map[string]*plugins.Plugin{ + p1.ID: p1, + p2.ID: p2, + p3.ID: p3, + }, + }) r := ps.Renderer(context.Background()) require.Equal(t, p1, r) @@ -155,12 +163,14 @@ func TestStore_SecretsManager(t *testing.T) { p3 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-secrets", Type: plugins.TypeSecretsManager}} p4 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-datasource", Type: plugins.TypeDataSource}} - ps := New(newFakePluginRegistry(map[string]*plugins.Plugin{ - p1.ID: p1, - p2.ID: p2, - p3.ID: p3, - p4.ID: p4, - })) + ps := New(&fakes.FakePluginRegistry{ + Store: map[string]*plugins.Plugin{ + p1.ID: p1, + p2.ID: p2, + p3.ID: p3, + p4.ID: p4, + }, + }) r := ps.SecretsManager(context.Background()) require.Equal(t, p3, r) @@ -173,12 +183,12 @@ func TestStore_availablePlugins(t *testing.T) { p1.RegisterClient(&DecommissionedPlugin{}) p2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-app"}} - ps := New( - newFakePluginRegistry(map[string]*plugins.Plugin{ + ps := New(&fakes.FakePluginRegistry{ + Store: map[string]*plugins.Plugin{ p1.ID: p1, p2.ID: p2, - }), - ) + }, + }) aps := ps.availablePlugins(context.Background()) require.Len(t, aps, 1) @@ -197,36 +207,3 @@ func (p *DecommissionedPlugin) Decommission() error { func (p *DecommissionedPlugin) IsDecommissioned() bool { return true } - -type fakePluginRegistry struct { - store map[string]*plugins.Plugin -} - -func newFakePluginRegistry(m map[string]*plugins.Plugin) *fakePluginRegistry { - return &fakePluginRegistry{ - store: m, - } -} - -func (f *fakePluginRegistry) Plugin(_ context.Context, id string) (*plugins.Plugin, bool) { - p, exists := f.store[id] - return p, exists -} - -func (f *fakePluginRegistry) Plugins(_ context.Context) []*plugins.Plugin { - var res []*plugins.Plugin - for _, p := range f.store { - res = append(res, p) - } - return res -} - -func (f *fakePluginRegistry) Add(_ context.Context, p *plugins.Plugin) error { - f.store[p.ID] = p - return nil -} - -func (f *fakePluginRegistry) Remove(_ context.Context, id string) error { - delete(f.store, id) - return nil -} From 3341229bc24e0aba2b6263ba6c66f45c0448db5d Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Fri, 9 Jun 2023 13:57:46 +0300 Subject: [PATCH 17/51] Chore: InfluxDB - Reformatting and restructuring (#69669) * Reformatting and restructuring * Update unit test * Export as function --- .betterer.results | 3 -- .../query/influxql/InfluxCheatSheet.tsx | 4 +- .../editor/query/influxql/InfluxStartPage.tsx | 12 ++--- .../query/influxql/code/RawInfluxQLEditor.tsx | 2 +- .../hooks/useRetentionPolicies.test.ts | 44 +++++++++++++++++ .../influxql/hooks/useRetentionPolicies.ts | 15 ++++++ .../hooks/useShadowedState.test.ts | 0 .../{ => influxql}/hooks/useShadowedState.ts | 0 .../editor/query/influxql/utils/filterTags.ts | 7 +++ .../utils/getTemplateVariableOptions.ts | 12 +++++ .../utils/withTemplateVariableOptions.ts | 16 ++++++ .../editor/query/influxql/utils/wrapper.ts | 9 ++++ .../query/influxql/visual/InputSection.tsx | 2 +- .../editor/query/influxql/visual/Seg.tsx | 2 +- .../influxql/visual/VisualInfluxQLEditor.tsx | 49 +++---------------- .../app/plugins/datasource/influxdb/module.ts | 2 +- .../datasource/influxdb/specs/mocks.ts | 2 +- 17 files changed, 119 insertions(+), 62 deletions(-) create mode 100644 public/app/plugins/datasource/influxdb/components/editor/query/influxql/hooks/useRetentionPolicies.test.ts create mode 100644 public/app/plugins/datasource/influxdb/components/editor/query/influxql/hooks/useRetentionPolicies.ts rename public/app/plugins/datasource/influxdb/components/editor/query/{ => influxql}/hooks/useShadowedState.test.ts (100%) rename public/app/plugins/datasource/influxdb/components/editor/query/{ => influxql}/hooks/useShadowedState.ts (100%) create mode 100644 public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/filterTags.ts create mode 100644 public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/getTemplateVariableOptions.ts create mode 100644 public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/withTemplateVariableOptions.ts create mode 100644 public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/wrapper.ts diff --git a/.betterer.results b/.betterer.results index a5ae922b47a..46ecf2ed512 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4144,9 +4144,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "public/app/plugins/datasource/influxdb/components/editor/query/influxql/InfluxCheatSheet.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/datasource/influxdb/datasource.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/InfluxCheatSheet.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/InfluxCheatSheet.tsx index 7bf562c2cff..04b573159c6 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/InfluxCheatSheet.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/InfluxCheatSheet.tsx @@ -8,7 +8,7 @@ const CHEAT_SHEET_ITEMS = [ }, ]; -const InfluxCheatSheet = (props: any) => ( +export const InfluxCheatSheet = () => (

InfluxDB Cheat Sheet

{CHEAT_SHEET_ITEMS.map((item) => ( @@ -19,5 +19,3 @@ const InfluxCheatSheet = (props: any) => ( ))}
); - -export default InfluxCheatSheet; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/InfluxStartPage.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/InfluxStartPage.tsx index f5ac86a24d2..10f35190d08 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/InfluxStartPage.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/InfluxStartPage.tsx @@ -1,11 +1,7 @@ -import React, { PureComponent } from 'react'; +import React from 'react'; -import { QueryEditorHelpProps } from '@grafana/data'; +import { InfluxCheatSheet } from './InfluxCheatSheet'; -import InfluxCheatSheet from './InfluxCheatSheet'; - -export default class InfluxStartPage extends PureComponent { - render() { - return ; - } +export function InfluxStartPage() { + return ; } diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/code/RawInfluxQLEditor.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/code/RawInfluxQLEditor.tsx index 887bec9ee93..0df57b7b26d 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/code/RawInfluxQLEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/code/RawInfluxQLEditor.tsx @@ -4,7 +4,7 @@ import { HorizontalGroup, InlineFormLabel, Input, Select, TextArea } from '@graf import { InfluxQuery } from '../../../../../types'; import { DEFAULT_RESULT_FORMAT, RESULT_FORMATS } from '../../../constants'; -import { useShadowedState } from '../../hooks/useShadowedState'; +import { useShadowedState } from '../hooks/useShadowedState'; type Props = { query: InfluxQuery; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/hooks/useRetentionPolicies.test.ts b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/hooks/useRetentionPolicies.test.ts new file mode 100644 index 00000000000..e4648e2f1e7 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/hooks/useRetentionPolicies.test.ts @@ -0,0 +1,44 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import config from 'app/core/config'; + +import { getMockDS, getMockDSInstanceSettings, mockBackendService } from '../../../../../specs/mocks'; + +import { useRetentionPolicies } from './useRetentionPolicies'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), +})); + +describe('useRetentionPolicies', () => { + it('should return all policies when influxdbBackendMigration feature toggle enabled', async () => { + const instanceSettings = getMockDSInstanceSettings(); + const datasource = getMockDS(instanceSettings); + mockBackendService(response); + + config.featureToggles.influxdbBackendMigration = true; + const { result, waitForNextUpdate } = renderHook(() => useRetentionPolicies(datasource)); + await waitForNextUpdate(); + expect(result.current.retentionPolicies.length).toEqual(4); + expect(result.current.retentionPolicies[0]).toEqual('autogen'); + }); +}); + +const response = { + data: { + results: { + metadataQuery: { + status: 200, + frames: [ + { + schema: { + refId: 'metadataQuery', + fields: [{ name: 'value', type: 'string', typeInfo: { frame: 'string' } }], + }, + data: { values: [['autogen', 'bar', '5m_avg', '1m_avg']] }, + }, + ], + }, + }, + }, +}; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/hooks/useRetentionPolicies.ts b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/hooks/useRetentionPolicies.ts new file mode 100644 index 00000000000..c25b795294c --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/hooks/useRetentionPolicies.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react'; + +import InfluxDatasource from '../../../../../datasource'; +import { getAllPolicies } from '../../../../../influxql_metadata_query'; + +export const useRetentionPolicies = (datasource: InfluxDatasource) => { + const [retentionPolicies, setRetentionPolicies] = useState([]); + useEffect(() => { + getAllPolicies(datasource).then((data) => { + setRetentionPolicies(data); + }); + }, [datasource]); + + return { retentionPolicies }; +}; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/hooks/useShadowedState.test.ts b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/hooks/useShadowedState.test.ts similarity index 100% rename from public/app/plugins/datasource/influxdb/components/editor/query/hooks/useShadowedState.test.ts rename to public/app/plugins/datasource/influxdb/components/editor/query/influxql/hooks/useShadowedState.test.ts diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/hooks/useShadowedState.ts b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/hooks/useShadowedState.ts similarity index 100% rename from public/app/plugins/datasource/influxdb/components/editor/query/hooks/useShadowedState.ts rename to public/app/plugins/datasource/influxdb/components/editor/query/influxql/hooks/useShadowedState.ts diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/filterTags.ts b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/filterTags.ts new file mode 100644 index 00000000000..904de835063 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/filterTags.ts @@ -0,0 +1,7 @@ +// it is possible to add fields into the `InfluxQueryTag` structures, and they do work, +// but in some cases, when we do metadata queries, we have to remove them from the queries. +import { InfluxQueryTag } from '../../../../../types'; + +export function filterTags(parts: InfluxQueryTag[], allTagKeys: Set): InfluxQueryTag[] { + return parts.filter((t) => t.key.endsWith('::tag') || allTagKeys.has(t.key + '::tag')); +} diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/getTemplateVariableOptions.ts b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/getTemplateVariableOptions.ts new file mode 100644 index 00000000000..51db862e4d9 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/getTemplateVariableOptions.ts @@ -0,0 +1,12 @@ +import { TypedVariableModel } from '@grafana/data/src'; +import { getTemplateSrv } from '@grafana/runtime/src'; + +export function getTemplateVariableOptions(wrapper: (v: TypedVariableModel) => string) { + return ( + getTemplateSrv() + .getVariables() + // we make them regex-params, i'm not 100% sure why. + // probably because this way multi-value variables work ok too. + .map(wrapper) + ); +} diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/withTemplateVariableOptions.ts b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/withTemplateVariableOptions.ts new file mode 100644 index 00000000000..89bd1372bcd --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/withTemplateVariableOptions.ts @@ -0,0 +1,16 @@ +// helper function to make it easy to call this from the widget-render-code +import { TypedVariableModel } from '@grafana/data/src'; + +import { getTemplateVariableOptions } from './getTemplateVariableOptions'; + +export function withTemplateVariableOptions( + optionsPromise: Promise, + wrapper: (v: TypedVariableModel) => string, + filter?: string +): Promise { + let templateVariableOptions = getTemplateVariableOptions(wrapper); + if (filter) { + templateVariableOptions = templateVariableOptions.filter((tvo) => tvo.indexOf(filter) > -1); + } + return optionsPromise.then((options) => [...templateVariableOptions, ...options]); +} diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/wrapper.ts b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/wrapper.ts new file mode 100644 index 00000000000..769a9a435a4 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/utils/wrapper.ts @@ -0,0 +1,9 @@ +import { TypedVariableModel } from '@grafana/data/src'; + +export function wrapRegex(v: TypedVariableModel): string { + return `/^$${v.name}$/`; +} + +export function wrapPure(v: TypedVariableModel): string { + return `$${v.name}`; +} diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/InputSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/InputSection.tsx index 6bf9e24e9b5..71b9fad90d3 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/InputSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/InputSection.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { Input } from '@grafana/ui'; -import { useShadowedState } from '../../hooks/useShadowedState'; +import { useShadowedState } from '../hooks/useShadowedState'; import { paddingRightClass } from './styles'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/Seg.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/Seg.tsx index 8a2077b0e66..28b679fd5e3 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/Seg.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/Seg.tsx @@ -6,7 +6,7 @@ import { useAsyncFn } from 'react-use'; import { SelectableValue } from '@grafana/data'; import { AsyncSelect, InlineLabel, Input, Select } from '@grafana/ui'; -import { useShadowedState } from '../../hooks/useShadowedState'; +import { useShadowedState } from '../hooks/useShadowedState'; // this file is a simpler version of `grafana-ui / SegmentAsync.tsx` // with some changes: diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx index 0a00132e9e1..21951edc28a 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx @@ -1,9 +1,7 @@ import { css } from '@emotion/css'; import React, { useId, useMemo } from 'react'; -import { useAsync } from 'react-use'; -import { GrafanaTheme2, TypedVariableModel } from '@grafana/data'; -import { getTemplateSrv } from '@grafana/runtime'; +import { GrafanaTheme2 } from '@grafana/data'; import { InlineLabel, SegmentSection, useStyles2 } from '@grafana/ui'; import InfluxDatasource from '../../../../../datasource'; @@ -25,7 +23,11 @@ import { } from '../../../../../queryUtils'; import { InfluxQuery, InfluxQueryTag } from '../../../../../types'; import { DEFAULT_RESULT_FORMAT } from '../../../constants'; +import { useRetentionPolicies } from '../hooks/useRetentionPolicies'; +import { filterTags } from '../utils/filterTags'; import { getNewGroupByPartOptions, getNewSelectPartOptions, makePartList } from '../utils/partListUtils'; +import { withTemplateVariableOptions } from '../utils/withTemplateVariableOptions'; +import { wrapPure, wrapRegex } from '../utils/wrapper'; import { FormatAsSection } from './FormatAsSection'; import { FromSection } from './FromSection'; @@ -41,43 +43,6 @@ type Props = { datasource: InfluxDatasource; }; -function wrapRegex(v: TypedVariableModel): string { - return `/^$${v.name}$/`; -} - -function wrapPure(v: TypedVariableModel): string { - return `$${v.name}`; -} - -function getTemplateVariableOptions(wrapper: (v: TypedVariableModel) => string) { - return ( - getTemplateSrv() - .getVariables() - // we make them regex-params, i'm not 100% sure why. - // probably because this way multi-value variables work ok too. - .map(wrapper) - ); -} - -// helper function to make it easy to call this from the widget-render-code -function withTemplateVariableOptions( - optionsPromise: Promise, - wrapper: (v: TypedVariableModel) => string, - filter?: string -): Promise { - let templateVariableOptions = getTemplateVariableOptions(wrapper); - if (filter) { - templateVariableOptions = templateVariableOptions.filter((tvo) => tvo.indexOf(filter) > -1); - } - return optionsPromise.then((options) => [...templateVariableOptions, ...options]); -} - -// it is possible to add fields into the `InfluxQueryTag` structures, and they do work, -// but in some cases, when we do metadata queries, we have to remove them from the queries. -function filterTags(parts: InfluxQueryTag[], allTagKeys: Set): InfluxQueryTag[] { - return parts.filter((t) => t.key.endsWith('::tag') || allTagKeys.has(t.key + '::tag')); -} - export const VisualInfluxQLEditor = (props: Props): JSX.Element => { const uniqueId = useId(); const formatAsId = `influxdb-qe-format-as-${uniqueId}`; @@ -87,9 +52,7 @@ export const VisualInfluxQLEditor = (props: Props): JSX.Element => { const query = normalizeQuery(props.query); const { datasource } = props; const { measurement, policy } = query; - - const policyData = useAsync(() => getAllPolicies(datasource), [datasource]); - const retentionPolicies = !!policyData.error ? [] : policyData.value ?? []; + const { retentionPolicies } = useRetentionPolicies(datasource); const allTagKeys = useMemo(async () => { const tagKeys = (await getTagKeysForMeasurementAndTags(datasource, [], measurement, policy)).map( diff --git a/public/app/plugins/datasource/influxdb/module.ts b/public/app/plugins/datasource/influxdb/module.ts index da229451278..c293c017d6f 100644 --- a/public/app/plugins/datasource/influxdb/module.ts +++ b/public/app/plugins/datasource/influxdb/module.ts @@ -2,7 +2,7 @@ import { DataSourcePlugin } from '@grafana/data'; import ConfigEditor from './components/editor/config/ConfigEditor'; import { QueryEditor } from './components/editor/query/QueryEditor'; -import InfluxStartPage from './components/editor/query/influxql/InfluxStartPage'; +import { InfluxStartPage } from './components/editor/query/influxql/InfluxStartPage'; import VariableQueryEditor from './components/editor/variable/VariableQueryEditor'; import InfluxDatasource from './datasource'; diff --git a/public/app/plugins/datasource/influxdb/specs/mocks.ts b/public/app/plugins/datasource/influxdb/specs/mocks.ts index 9402f08aa44..72c01d4453a 100644 --- a/public/app/plugins/datasource/influxdb/specs/mocks.ts +++ b/public/app/plugins/datasource/influxdb/specs/mocks.ts @@ -58,6 +58,6 @@ export function getMockDSInstanceSettings(): DataSourceInstanceSettings Date: Fri, 9 Jun 2023 14:22:35 +0200 Subject: [PATCH 18/51] LogsPanel: Remove top margin (#69847) * LogsPanel: Remove top margin * Fixing --- public/app/plugins/panel/logs/LogsPanel.tsx | 39 +++++++++++---------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 3aff98323e9..85ab80ae0b5 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -1,4 +1,4 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React, { useCallback, useMemo, useRef, useLayoutEffect, useState } from 'react'; import { @@ -42,7 +42,7 @@ export const LogsPanel = ({ id, }: LogsPanelProps) => { const isAscending = sortOrder === LogsSortOrder.Ascending; - const style = useStyles2(getStyles(title, isAscending)); + const style = useStyles2(getStyles); const [scrollTop, setScrollTop] = useState(0); const logsContainerRef = useRef(null); @@ -95,7 +95,7 @@ export const LogsPanel = ({ } const renderCommonLabels = () => ( -
+
Common labels:
@@ -127,20 +127,21 @@ export const LogsPanel = ({ ); }; -const getStyles = (title: string, isAscending: boolean) => (theme: GrafanaTheme2) => ({ - container: css` - margin-bottom: ${theme.spacing(1.5)}; - //We can remove this hot-fix when we fix panel menu with no title overflowing top of all panels - margin-top: ${theme.spacing(!title ? 2.5 : 0)}; - `, - labelContainer: css` - margin: ${isAscending ? theme.spacing(0.5, 0, 0.5, 0) : theme.spacing(0, 0, 0.5, 0.5)}; - display: flex; - align-items: center; - `, - label: css` - margin-right: ${theme.spacing(0.5)}; - font-size: ${theme.typography.bodySmall.fontSize}; - font-weight: ${theme.typography.fontWeightMedium}; - `, +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + marginBottom: theme.spacing(1.5), + }), + labelContainer: css({ + margin: theme.spacing(0, 0, 0.5, 0.5), + display: 'flex', + alignItems: 'center', + }), + labelContainerAscending: css({ + margin: theme.spacing(0.5, 0, 0.5, 0), + }), + label: css({ + marginRight: theme.spacing(0.5), + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + }), }); From c731d9fe2c62529da046c3442f01d64832e51362 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Fri, 9 Jun 2023 15:26:32 +0200 Subject: [PATCH 19/51] Authentication UI: Add metric for the first usage (#69779) --- public/app/features/auth-config/AuthConfigPage.tsx | 8 ++++++++ .../features/auth-config/components/ConfigureAuthCTA.tsx | 3 +++ 2 files changed, 11 insertions(+) diff --git a/public/app/features/auth-config/AuthConfigPage.tsx b/public/app/features/auth-config/AuthConfigPage.tsx index 2628ef2c599..388181e2aa3 100644 --- a/public/app/features/auth-config/AuthConfigPage.tsx +++ b/public/app/features/auth-config/AuthConfigPage.tsx @@ -4,6 +4,7 @@ import React, { useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { StoreState } from 'app/types'; @@ -63,7 +64,13 @@ export const AuthConfigPageUnconnected = ({ providerStatuses, isLoading, loadSet documentation. ); + const subTitle = Manage your auth settings and configure single sign-on. Find out more in our {docsLink}; + + const onCTAClick = () => { + reportInteraction('authentication_ui_created', { provider: firstAvailableProvider?.type }); + }; + return ( @@ -92,6 +99,7 @@ export const AuthConfigPageUnconnected = ({ providerStatuses, isLoading, loadSet description={`Important: if you have ${firstAvailableProvider.type} configuration enabled via the .ini file Grafana is using it. Configuring ${firstAvailableProvider.type} via UI will take precedence over any configuration in the .ini file. No changes will be written into .ini file.`} + onClick={onCTAClick} /> )} {!!configuresProviders?.length && ( diff --git a/public/app/features/auth-config/components/ConfigureAuthCTA.tsx b/public/app/features/auth-config/components/ConfigureAuthCTA.tsx index 78892b0d874..fca98251f28 100644 --- a/public/app/features/auth-config/components/ConfigureAuthCTA.tsx +++ b/public/app/features/auth-config/components/ConfigureAuthCTA.tsx @@ -12,6 +12,7 @@ export interface Props { buttonTitle: string; buttonDisabled?: boolean; description?: string; + onClick?: () => void; } const ConfigureAuthCTA: React.FunctionComponent = ({ @@ -21,6 +22,7 @@ const ConfigureAuthCTA: React.FunctionComponent = ({ buttonTitle, buttonDisabled, description, + onClick, }) => { const styles = useStyles2(getStyles); const footer = description ? {description} : ''; @@ -34,6 +36,7 @@ const ConfigureAuthCTA: React.FunctionComponent = ({ className={ctaElementClassName} data-testid={selectors.components.CallToActionCard.buttonV2(buttonTitle)} disabled={buttonDisabled} + onClick={() => onClick && onClick()} > {buttonTitle} From 840e8d977236ecbe5abc2bc169653e0566f72a3b Mon Sep 17 00:00:00 2001 From: David Harris Date: Fri, 9 Jun 2023 14:32:30 +0100 Subject: [PATCH 20/51] docs: update list on new detections (#69761) * docs: update list on new detections * docs: remove updated plugins, formatting * re-add Oracle plugins --- .../angular_deprecation/angular-plugins.md | 74 ++++++++++++++----- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/docs/sources/developers/angular_deprecation/angular-plugins.md b/docs/sources/developers/angular_deprecation/angular-plugins.md index 28b5509e59d..41272189af0 100644 --- a/docs/sources/developers/angular_deprecation/angular-plugins.md +++ b/docs/sources/developers/angular_deprecation/angular-plugins.md @@ -41,10 +41,30 @@ Guidance on migrating a plugin to React can be found in our [migration guide]({{ ## Apps +### [BelugaCDN](https://grafana.com/grafana/plugins/belugacdn-app) + +Latest Version: 1.2.1 | Signature: Commercial | Last Updated: 2023 + +> [Migration issue](https://github.com/belugacdn/grafana-belugacdn-app/issues/7) has been raised. + +> **Warning:** Lack of recent activity in the [project repository](https://github.com/belugacdn/grafana-belugacdn-app) in the past 7 years suggests project _may_ not be actively maintained. + +### [Bosun](https://grafana.com/grafana/plugins/bosun-app) + +Latest Version: 0.0.29 | Signature: Community | Last Updated: 2023 + +> [Migration issue](https://github.com/bosun-monitor/bosun-grafana-app/issues/63) has been raised. + ### [Cloudflare Grafana App](https://grafana.com/grafana/plugins/cloudflare-app/) Latest Version: 0.2.4 | Signature: Commercial | Last Updated: 2022 +### [GLPI](https://grafana.com/grafana/plugins/ddurieux-glpi-app) + +Latest Version: 1.3.1 | Signature: Community | Last Updated: 2021 + +> [Migration issue](https://github.com/ddurieux/glpi_app_grafana/issues/96) has been raised. + ### [DevOpsProdigy KubeGraf](https://grafana.com/grafana/plugins/devopsprodigy-kubegraf-app/) Latest Version: 1.5.2 | Signature: Community | Last Updated: 2021 @@ -53,10 +73,46 @@ Latest Version: 1.5.2 | Signature: Community | Last Updated: 2021 > **Migration available - potential alternative:** Grafana Cloud includes a [Kubernetes integration](https://grafana.com/solutions/kubernetes/). +### [AWS IoT TwinMaker App](https://grafana.com/grafana/plugins/grafana-iot-twinmaker-app) + +Latest Version: 1.6.2 | Signature: Commercial | Last Updated: 2023 + +> **Note:** Plugin should continue to work even if Angular is disabled, and a full removal of Angular related code is planned. + ### [Kentik Connect Pro](https://grafana.com/grafana/plugins/kentik-connect-app/) Latest Version: 1.6.2 | Signature: Commercial | Last Updated: 2023 +### [Moogsoft AIOps](https://grafana.com/grafana/plugins/moogsoft-aiops-app) + +Latest Version: 8.0.2 | Signature: Commercial | Last Updated: 2022 + +### [OpenNMS Helm](https://grafana.com/grafana/plugins/opennms-helm-app) + +Latest Version: 8.0.4 | Signature: Community | Last Updated: 2023 + +> **Migration available - plugin superseded:** The plugin has effectively been replaced with a [new plugin](https://grafana.com/grafana/plugins/opennms-opennms-app/) based on React. + +### [Percona](https://grafana.com/grafana/plugins/percona-percona-app/) + +Latest Version: 1.0.1 | Signature: Community | Last Updated: 2021 + +> **Warning:** [Project repository](https://github.com/percona/grafana-app) was archived on June 12, 2020. + +### [Stagemonitor Elasticsearch](https://grafana.com/grafana/plugins/stagemonitor-elasticsearch-app) + +Latest Version: 0.83.3 | Signature: Community | Last Updated: 2021 + +> [Migration issue](https://github.com/stagemonitor/stagemonitor-grafana-elasticsearch/issues/1) has been raised. + +> **Warning:** Lack of recent activity in the [project repository](https://github.com/stagemonitor/stagemonitor-grafana-elasticsearch) in the past 4 years suggests project _may_ not be actively maintained. + +### [Voxter VoIP Platform Metrics](https://grafana.com/grafana/plugins/voxter-app) + +Latest Version: 0.0.2 | Signature: Community | Last Updated: 2021 + +> **Warning:** Lack of recent activity in the [project repository](https://github.com/raintank/voxter-app) in the past 3 years suggests project _may_ not be actively maintained. + ## Datasources ### [Druid](https://grafana.com/grafana/plugins/abhisant-druid-datasource/) @@ -103,10 +159,6 @@ Latest Version: 2.2.3 | Signature: Community | Last Updated: 2022 > **Warning:** Lack of recent activity in the [project repository](https://github.com/chaos-mesh/datasource) in the past year suggests project _may_ not be actively maintained. -### [Cognite Data Fusion](https://grafana.com/grafana/plugins/cognitedata-datasource/) - -Latest Version: 3.0.0 | Signature: Commercial | Last Updated: 2023 - ### [DeviceHive](https://grafana.com/grafana/plugins/devicehive-devicehive-datasource/) Latest Version: 2.0.2 | Signature: Community | Last Updated: 2021 @@ -185,12 +237,6 @@ Latest Version: 1.4.2 | Signature: Grafana | Last Updated: 2021 > **Note:** If you're looking for an example of a data source plugin to start from, refer to [grafana-starter-datasource-backend](https://github.com/grafana/grafana-starter-datasource-backend). -### [Splunk](https://grafana.com/grafana/plugins/grafana-splunk-datasource/) - -Latest Version: 4.1.6 | Signature: Grafana | Last Updated: 2023 - -> **Note:** Removal of any angular dependency is on the near term roadmap. - ### [Strava](https://grafana.com/grafana/plugins/grafana-strava-datasource/) Latest Version: 1.5.1 | Signature: Grafana | Last Updated: 2022 @@ -203,12 +249,6 @@ Latest Version: 1.0.3 | Signature: Community | Last Updated: 2021 > **Warning:** Lack of recent activity in the [project repository](https://github.com/GridProtectionAlliance/openHistorian-grafana/) in the past 2 years suggests project _may_ not be actively maintained. -### [OSIsoft-PI](https://grafana.com/grafana/plugins/gridprotectionalliance-osisoftpi-datasource/) - -Latest Version: 3.1.0 | Signature: Community | Last Updated: 2023 - -> **Note:** Fixed in 4.0.0 which should be published soon - [source](https://github.com/GridProtectionAlliance/osisoftpi-grafana/issues/119#issuecomment-1493566212). - ### [Hawkular](https://grafana.com/grafana/plugins/hawkular-datasource/) Latest Version: 1.1.2 | Signature: Community | Last Updated: 2021 @@ -283,7 +323,7 @@ Latest Version: 3.0.0 | Signature: Commercial | Last Updated: 2023 ### [Oracle Cloud Infrastructure Metrics](https://grafana.com/grafana/plugins/oci-metrics-datasource/) -Latest Version: 4.0.0 | Signature: Commercial | Last Updated: 2023 +Latest Version: 4.0.1 | Signature: Commercial | Last Updated: 2023 ### [Warp 10](https://grafana.com/grafana/plugins/ovh-warp10-datasource/) From ff3e028a8588ab3f37410e44d51a5550744bf291 Mon Sep 17 00:00:00 2001 From: Santiago Date: Fri, 9 Jun 2023 10:59:24 -0300 Subject: [PATCH 21/51] Alerting: Add image URI annotation only when there's an image (#69825) * Alerting: Add image URI annotation only when there's an image * fix function name (changed on main branch) --- pkg/services/ngalert/state/compat.go | 11 +++++++++-- pkg/services/ngalert/state/compat_test.go | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/pkg/services/ngalert/state/compat.go b/pkg/services/ngalert/state/compat.go index 70d97e474d8..fbe934bc344 100644 --- a/pkg/services/ngalert/state/compat.go +++ b/pkg/services/ngalert/state/compat.go @@ -49,7 +49,10 @@ func StateToPostableAlert(alertState *State, appURL *url.URL) *models.PostableAl } if alertState.Image != nil { - nA[alertingModels.ImageTokenAnnotation] = generateImageURI(alertState.Image) + imageURI := generateImageURI(alertState.Image) + if imageURI != "" { + nA[alertingModels.ImageTokenAnnotation] = imageURI + } } if alertState.StateReason != "" { @@ -174,5 +177,9 @@ func generateImageURI(image *ngModels.Image) string { if image.URL != "" { return image.URL } - return "token://" + image.Token + if image.Token != "" { + return "token://" + image.Token + } + + return "" } diff --git a/pkg/services/ngalert/state/compat_test.go b/pkg/services/ngalert/state/compat_test.go index a33551cc27f..f98f32ff7a1 100644 --- a/pkg/services/ngalert/state/compat_test.go +++ b/pkg/services/ngalert/state/compat_test.go @@ -133,6 +133,21 @@ func Test_StateToPostableAlert(t *testing.T) { require.Equal(t, expected, result.Annotations) }) + + t.Run("don't add __alertImageToken__ if there's no image token", func(t *testing.T) { + alertState := randomState(tc.state) + alertState.Annotations = randomMapOfStrings() + alertState.Image = &ngModels.Image{} + + result := StateToPostableAlert(alertState, appURL) + + expected := make(models.LabelSet, len(alertState.Annotations)+1) + for k, v := range alertState.Annotations { + expected[k] = v + } + + require.Equal(t, expected, result.Annotations) + }) }) t.Run("should add state reason annotation if not empty", func(t *testing.T) { From a5b9eac88ef4bee1487baa11e1682fa00d17605e Mon Sep 17 00:00:00 2001 From: Ibrahim <93064150+IbrahimCSAE@users.noreply.github.com> Date: Fri, 9 Jun 2023 10:57:56 -0400 Subject: [PATCH 22/51] Transformations: Config overrides being lost when config from query transform is applied (#69720) fix config overides being lost by transforms --- .../features/transformers/configFromQuery/configFromQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/transformers/configFromQuery/configFromQuery.ts b/public/app/features/transformers/configFromQuery/configFromQuery.ts index 5b3bc7cb2c6..0ac2fc25788 100644 --- a/public/app/features/transformers/configFromQuery/configFromQuery.ts +++ b/public/app/features/transformers/configFromQuery/configFromQuery.ts @@ -66,6 +66,7 @@ export function extractConfigFromQuery(options: ConfigFromQueryTransformOptions, const outputFrame: DataFrame = { fields: [], length: frame.length, + refId: frame.refId, }; for (const field of frame.fields) { @@ -85,7 +86,6 @@ export function extractConfigFromQuery(options: ConfigFromQueryTransformOptions, output.push(outputFrame); } - return output; } From ca8d0ef041d65e77d929e340d93fe48f6434fc1c Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 9 Jun 2023 16:00:16 +0100 Subject: [PATCH 23/51] NestedFolders: Move `New folder` into a drawer (#69706) * make New folder a drawer * use sentence case * extract strings and update tests * use sm drawer --- .../BrowseDashboardsPage.tsx | 5 +- .../components/CreateNewButton.test.tsx | 39 ++++++++--- .../components/CreateNewButton.tsx | 69 +++++++++++++++---- .../components/NewFolderForm.tsx | 59 ++++++++++++++++ public/app/features/search/tempI18nPhrases.ts | 4 +- public/locales/en-US/grafana.json | 4 +- public/locales/pseudo-LOCALE/grafana.json | 4 +- 7 files changed, 150 insertions(+), 34 deletions(-) create mode 100644 public/app/features/browse-dashboards/components/NewFolderForm.tsx diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index c67fe825014..e2c1700633c 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -16,7 +16,7 @@ import { skipToken, useGetFolderQuery, useSaveFolderMutation } from './api/brows import { BrowseActions } from './components/BrowseActions/BrowseActions'; import { BrowseFilters } from './components/BrowseFilters'; import { BrowseView } from './components/BrowseView'; -import { CreateNewButton } from './components/CreateNewButton'; +import CreateNewButton from './components/CreateNewButton'; import { FolderActionsButton } from './components/FolderActionsButton'; import { SearchView } from './components/SearchView'; import { getFolderPermissions } from './permissions'; @@ -104,7 +104,8 @@ const BrowseDashboardsPage = memo(({ match }: Props) => { {folderDTO && } {(canCreateDashboards || canCreateFolder) && ( diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.test.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.test.tsx index d81b3818161..d5811f9903d 100644 --- a/public/app/features/browse-dashboards/components/CreateNewButton.test.tsx +++ b/public/app/features/browse-dashboards/components/CreateNewButton.test.tsx @@ -1,11 +1,16 @@ -import { render, screen } from '@testing-library/react'; +import { render as rtlRender, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; -import { CreateNewButton } from './CreateNewButton'; +import CreateNewButton from './CreateNewButton'; + +function render(...[ui, options]: Parameters) { + rtlRender({ui}, options); +} async function renderAndOpen(folderUID?: string) { - render(); + render(); const newButton = screen.getByText('New'); await userEvent.click(newButton); } @@ -14,27 +19,39 @@ describe('NewActionsButton', () => { it('should display the correct urls with a given folderUID', async () => { await renderAndOpen('123'); - expect(screen.getByText('New Dashboard')).toHaveAttribute('href', '/dashboard/new?folderUid=123'); - expect(screen.getByText('New Folder')).toHaveAttribute('href', '/dashboards/folder/new?folderUid=123'); + expect(screen.getByText('New dashboard')).toHaveAttribute('href', '/dashboard/new?folderUid=123'); expect(screen.getByText('Import')).toHaveAttribute('href', '/dashboard/import?folderUid=123'); }); it('should display urls without params when there is no folderUID', async () => { await renderAndOpen(); - expect(screen.getByText('New Dashboard')).toHaveAttribute('href', '/dashboard/new'); - expect(screen.getByText('New Folder')).toHaveAttribute('href', '/dashboards/folder/new'); + expect(screen.getByText('New dashboard')).toHaveAttribute('href', '/dashboard/new'); expect(screen.getByText('Import')).toHaveAttribute('href', '/dashboard/import'); }); + it('clicking the "New folder" button opens the drawer', async () => { + const mockParentFolderTitle = 'mockParentFolderTitle'; + render(); + + const newButton = screen.getByText('New'); + await userEvent.click(newButton); + await userEvent.click(screen.getByText('New folder')); + + const drawer = screen.getByRole('dialog', { name: 'Drawer title New folder' }); + expect(drawer).toBeInTheDocument(); + expect(within(drawer).getByRole('heading', { name: 'New folder' })).toBeInTheDocument(); + expect(within(drawer).getByText(`Location: ${mockParentFolderTitle}`)).toBeInTheDocument(); + }); + it('should only render dashboard items when folder creation is disabled', async () => { render(); const newButton = screen.getByText('New'); await userEvent.click(newButton); - expect(screen.getByText('New Dashboard')).toBeInTheDocument(); + expect(screen.getByText('New dashboard')).toBeInTheDocument(); expect(screen.getByText('Import')).toBeInTheDocument(); - expect(screen.queryByText('New Folder')).not.toBeInTheDocument(); + expect(screen.queryByText('New folder')).not.toBeInTheDocument(); }); it('should only render folder item when dashboard creation is disabled', async () => { @@ -42,8 +59,8 @@ describe('NewActionsButton', () => { const newButton = screen.getByText('New'); await userEvent.click(newButton); - expect(screen.queryByText('New Dashboard')).not.toBeInTheDocument(); + expect(screen.queryByText('New dashboard')).not.toBeInTheDocument(); expect(screen.queryByText('Import')).not.toBeInTheDocument(); - expect(screen.getByText('New Folder')).toBeInTheDocument(); + expect(screen.getByText('New folder')).toBeInTheDocument(); }); }); diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.tsx index c1757a4a744..6d9b1367313 100644 --- a/public/app/features/browse-dashboards/components/CreateNewButton.tsx +++ b/public/app/features/browse-dashboards/components/CreateNewButton.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; -import { Button, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; +import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; +import { createNewFolder } from 'app/features/folders/state/actions'; import { getNewDashboardPhrase, getNewFolderPhrase, @@ -8,41 +10,78 @@ import { getNewPhrase, } from 'app/features/search/tempI18nPhrases'; -interface Props { +import { NewFolderForm } from './NewFolderForm'; + +const mapDispatchToProps = { + createNewFolder, +}; + +const connector = connect(null, mapDispatchToProps); + +interface OwnProps { + parentFolderTitle?: string; /** * Pass a folder UID in which the dashboard or folder will be created */ - inFolder?: string; + parentFolderUid?: string; canCreateFolder: boolean; canCreateDashboard: boolean; } -export function CreateNewButton({ inFolder, canCreateDashboard, canCreateFolder }: Props) { +type Props = OwnProps & ConnectedProps; + +function CreateNewButton({ + parentFolderTitle, + parentFolderUid, + canCreateDashboard, + canCreateFolder, + createNewFolder, +}: Props) { const [isOpen, setIsOpen] = useState(false); + const [showNewFolderDrawer, setShowNewFolderDrawer] = useState(false); + + const onCreateFolder = (folderName: string) => { + createNewFolder(folderName, parentFolderUid); + setShowNewFolderDrawer(false); + }; + const newMenu = ( {canCreateDashboard && ( - - )} - {canCreateFolder && ( - + )} + {canCreateFolder && setShowNewFolderDrawer(true)} label={getNewFolderPhrase()} />} {canCreateDashboard && ( - + )} ); return ( - - - + <> + + + + {showNewFolderDrawer && ( + setShowNewFolderDrawer(false)} + size="sm" + > + setShowNewFolderDrawer(false)} /> + + )} + ); } +export default connector(CreateNewButton); + /** * * @param url without any parameters diff --git a/public/app/features/browse-dashboards/components/NewFolderForm.tsx b/public/app/features/browse-dashboards/components/NewFolderForm.tsx new file mode 100644 index 00000000000..9a426df9d1a --- /dev/null +++ b/public/app/features/browse-dashboards/components/NewFolderForm.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +import { Button, Input, Form, Field, HorizontalGroup } from '@grafana/ui'; + +import { validationSrv } from '../../manage-dashboards/services/ValidationSrv'; + +interface Props { + onConfirm: (folderName: string) => void; + onCancel: () => void; +} + +interface FormModel { + folderName: string; +} + +const initialFormModel: FormModel = { folderName: '' }; + +export function NewFolderForm({ onCancel, onConfirm }: Props) { + const validateFolderName = async (folderName: string) => { + try { + await validationSrv.validateNewFolderName(folderName); + return true; + } catch (e) { + if (e instanceof Error) { + return e.message; + } else { + throw e; + } + } + }; + + return ( +
onConfirm(form.folderName)}> + {({ register, errors }) => ( + <> + + await validateFolderName(v), + })} + /> + + + + + + + )} +
+ ); +} diff --git a/public/app/features/search/tempI18nPhrases.ts b/public/app/features/search/tempI18nPhrases.ts index 83b99bf2570..40c276270ae 100644 --- a/public/app/features/search/tempI18nPhrases.ts +++ b/public/app/features/search/tempI18nPhrases.ts @@ -10,11 +10,11 @@ export function getSearchPlaceholder(includePanels = false) { } export function getNewDashboardPhrase() { - return t('search.dashboard-actions.new-dashboard', 'New Dashboard'); + return t('search.dashboard-actions.new-dashboard', 'New dashboard'); } export function getNewFolderPhrase() { - return t('search.dashboard-actions.new-folder', 'New Folder'); + return t('search.dashboard-actions.new-folder', 'New folder'); } export function getImportPhrase() { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 920c860799c..f789b63014b 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -408,8 +408,8 @@ "dashboard-actions": { "import": "Import", "new": "New", - "new-dashboard": "New Dashboard", - "new-folder": "New Folder" + "new-dashboard": "New dashboard", + "new-folder": "New folder" }, "folder-view": { "go-to-folder": "Go to folder", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index bbfc8c0ab83..f0f3b927701 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -408,8 +408,8 @@ "dashboard-actions": { "import": "Ĩmpőřŧ", "new": "Ńęŵ", - "new-dashboard": "Ńęŵ Đäşĥþőäřđ", - "new-folder": "Ńęŵ Főľđęř" + "new-dashboard": "Ńęŵ đäşĥþőäřđ", + "new-folder": "Ńęŵ ƒőľđęř" }, "folder-view": { "go-to-folder": "Ğő ŧő ƒőľđęř", From 516baf59fba37d277e284c34bbfdc399d179b50b Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Fri, 9 Jun 2023 18:21:32 +0300 Subject: [PATCH 24/51] StyleGuide: Add testing guide (#69403) * StyleGuide: Testing Select * Add mocking section * Fixes * Add examples of backendSrv mocks * Minor tweaks * Updates after review * Update examples --- contribute/style-guides/testing.md | 225 ++++++++++++++++++ .../core/components/Select/OrgPicker.test.tsx | 48 ++++ .../app/core/components/Select/OrgPicker.tsx | 2 +- 3 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 contribute/style-guides/testing.md create mode 100644 public/app/core/components/Select/OrgPicker.test.tsx diff --git a/contribute/style-guides/testing.md b/contribute/style-guides/testing.md new file mode 100644 index 00000000000..7ca50d926b9 --- /dev/null +++ b/contribute/style-guides/testing.md @@ -0,0 +1,225 @@ +# Testing Guidelines + +The goal of this document is to address the most frequently asked "How to" questions related to unit testing. + +## Best practices + +- Default to the `*ByRole` queries when testing components as it encourages testing with accessibility concerns in mind. It's also possible to use `*ByLabelText` queries. However, the `*ByRole` queries are [more robust](https://testing-library.com/docs/queries/bylabeltext/#name) and are generally recommended over the former. + +## Testing User Interactions + +We use the [user-event](https://testing-library.com/docs/user-event/intro) library for simulating user interactions during testing. This library is preferred over the built-in `fireEvent` method, as it more accurately mirrors real user interactions with elements. + +There are two important considerations when working with `userEvent`: + +1. All methods in `userEvent` are asynchronous, and thus require the use of `await` when called. +2. Directly calling methods from `userEvent` may not be supported in future versions. As such, it's necessary to first call `userEvent.setup()` prior to the tests. This method returns a `userEvent` instance, complete with all its methods. This setup process can be simplified using a utility function: + +```tsx +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +function setup(jsx: JSX.Element) { + return { + user: userEvent.setup(), + ...render(jsx), + }; +} + +it('should render', async () => { + const { user } = setup(