From 5aabc0781cac7048fa29084b2307714e36d5429e Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 18 Dec 2024 15:56:19 +0000 Subject: [PATCH 01/32] Chore: remove `@types/dompurify` since the main package exports types now (#98196) remove @types/dompurify since the main package exports types now --- packages/grafana-data/package.json | 3 +-- yarn.lock | 38 +++++++++++------------------- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index cf2e413abc6..d7647922dd1 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -41,7 +41,7 @@ "@types/string-hash": "1.1.3", "d3-interpolate": "3.0.1", "date-fns": "4.1.0", - "dompurify": "^3.0.0", + "dompurify": "3.2.3", "eventemitter3": "5.0.1", "fast_array_intersect": "1.1.0", "history": "4.10.1", @@ -63,7 +63,6 @@ "devDependencies": { "@grafana/tsconfig": "^2.0.0", "@rollup/plugin-node-resolve": "16.0.0", - "@types/dompurify": "^3.0.0", "@types/history": "4.7.11", "@types/lodash": "4.17.13", "@types/node": "22.10.2", diff --git a/yarn.lock b/yarn.lock index cc28a3aa173..d6ee220198e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3286,7 +3286,6 @@ __metadata: "@grafana/tsconfig": "npm:^2.0.0" "@rollup/plugin-node-resolve": "npm:16.0.0" "@types/d3-interpolate": "npm:^3.0.0" - "@types/dompurify": "npm:^3.0.0" "@types/history": "npm:4.7.11" "@types/lodash": "npm:4.17.13" "@types/node": "npm:22.10.2" @@ -3297,7 +3296,7 @@ __metadata: "@types/tinycolor2": "npm:1.4.6" d3-interpolate: "npm:3.0.1" date-fns: "npm:4.1.0" - dompurify: "npm:^3.0.0" + dompurify: "npm:3.2.3" esbuild: "npm:0.24.0" eventemitter3: "npm:5.0.1" fast_array_intersect: "npm:1.1.0" @@ -9129,15 +9128,6 @@ __metadata: languageName: node linkType: hard -"@types/dompurify@npm:^3.0.0": - version: 3.0.5 - resolution: "@types/dompurify@npm:3.0.5" - dependencies: - "@types/trusted-types": "npm:*" - checksum: 10/e544b3ce53c41215cabff3d89256ff707c7ee8e0c9a1b5034b22014725d288b16e6942cdcdeeb4221c578c3421a6a4721aa0676431f55d7abd18c07368855c5e - languageName: node - linkType: hard - "@types/eslint-scope@npm:^3.7.7": version: 3.7.7 resolution: "@types/eslint-scope@npm:3.7.7" @@ -10004,7 +9994,7 @@ __metadata: languageName: node linkType: hard -"@types/trusted-types@npm:*, @types/trusted-types@npm:^2.0.7": +"@types/trusted-types@npm:^2.0.7": version: 2.0.7 resolution: "@types/trusted-types@npm:2.0.7" checksum: 10/8e4202766a65877efcf5d5a41b7dd458480b36195e580a3b1085ad21e948bc417d55d6f8af1fd2a7ad008015d4117d5fdfe432731157da3c68678487174e4ba3 @@ -14724,6 +14714,18 @@ __metadata: languageName: node linkType: hard +"dompurify@npm:3.2.3": + version: 3.2.3 + resolution: "dompurify@npm:3.2.3" + dependencies: + "@types/trusted-types": "npm:^2.0.7" + dependenciesMeta: + "@types/trusted-types": + optional: true + checksum: 10/aad472bcdff40afdbb307fd02abbca86acefee9c39cb35e9634ebbc5e047750a7eeb021b02cd66894d60cf75ad021f69394de2e9e8786b0dd91c5832f497a9af + languageName: node + linkType: hard + "dompurify@npm:=3.1.6": version: 3.1.6 resolution: "dompurify@npm:3.1.6" @@ -14738,18 +14740,6 @@ __metadata: languageName: node linkType: hard -"dompurify@npm:^3.0.0": - version: 3.2.3 - resolution: "dompurify@npm:3.2.3" - dependencies: - "@types/trusted-types": "npm:^2.0.7" - dependenciesMeta: - "@types/trusted-types": - optional: true - checksum: 10/aad472bcdff40afdbb307fd02abbca86acefee9c39cb35e9634ebbc5e047750a7eeb021b02cd66894d60cf75ad021f69394de2e9e8786b0dd91c5832f497a9af - languageName: node - linkType: hard - "domutils@npm:^2.5.2, domutils@npm:^2.6.0": version: 2.8.0 resolution: "domutils@npm:2.8.0" From 59f71566df4d7ddfba2780ee25d19abf014c597b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Dec 2024 18:16:21 +0200 Subject: [PATCH 02/32] Update dependency @rtk-query/codegen-openapi to v2 (#98135) * Update dependency @rtk-query/codegen-openapi to v2 * update cloud migration generated endpoints --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison --- package.json | 2 +- .../migrate-to-cloud/api/endpoints.gen.ts | 17 +++-- yarn.lock | 69 ++++++++++++++----- 3 files changed, 63 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 941623fd98a..06886425fc0 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "@react-types/overlays": "3.8.11", "@react-types/shared": "3.26.0", "@rsdoctor/webpack-plugin": "^0.4.6", - "@rtk-query/codegen-openapi": "^1.2.0", + "@rtk-query/codegen-openapi": "^2.0.0", "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", "@stylistic/eslint-plugin-ts": "^2.9.0", "@swc/core": "1.10.1", diff --git a/public/app/features/migrate-to-cloud/api/endpoints.gen.ts b/public/app/features/migrate-to-cloud/api/endpoints.gen.ts index feb1034ee31..684f086757d 100644 --- a/public/app/features/migrate-to-cloud/api/endpoints.gen.ts +++ b/public/app/features/migrate-to-cloud/api/endpoints.gen.ts @@ -23,7 +23,10 @@ const injectedRtkApi = api.injectEndpoints({ getSnapshot: build.query({ query: (queryArg) => ({ url: `/cloudmigration/migration/${queryArg.uid}/snapshot/${queryArg.snapshotUid}`, - params: { resultPage: queryArg.resultPage, resultLimit: queryArg.resultLimit }, + params: { + resultPage: queryArg.resultPage, + resultLimit: queryArg.resultLimit, + }, }), }), cancelSnapshot: build.mutation({ @@ -41,7 +44,11 @@ const injectedRtkApi = api.injectEndpoints({ getShapshotList: build.query({ query: (queryArg) => ({ url: `/cloudmigration/migration/${queryArg.uid}/snapshots`, - params: { page: queryArg.page, limit: queryArg.limit, sort: queryArg.sort }, + params: { + page: queryArg.page, + limit: queryArg.limit, + sort: queryArg.sort, + }, }), }), getCloudMigrationToken: build.query({ @@ -95,14 +102,14 @@ export type GetSnapshotApiArg = { /** UID of a snapshot */ snapshotUid: string; }; -export type CancelSnapshotApiResponse = /** status 200 (empty) */ void; +export type CancelSnapshotApiResponse = unknown; export type CancelSnapshotApiArg = { /** Session UID of a session */ uid: string; /** UID of a snapshot */ snapshotUid: string; }; -export type UploadSnapshotApiResponse = /** status 200 (empty) */ void; +export type UploadSnapshotApiResponse = unknown; export type UploadSnapshotApiArg = { /** Session UID of a session */ uid: string; @@ -124,7 +131,7 @@ export type GetCloudMigrationTokenApiResponse = /** status 200 (empty) */ GetAcc export type GetCloudMigrationTokenApiArg = void; export type CreateCloudMigrationTokenApiResponse = /** status 200 (empty) */ CreateAccessTokenResponseDto; export type CreateCloudMigrationTokenApiArg = void; -export type DeleteCloudMigrationTokenApiResponse = /** status 204 (empty) */ void; +export type DeleteCloudMigrationTokenApiResponse = unknown; export type DeleteCloudMigrationTokenApiArg = { /** UID of a cloud migration token */ uid: string; diff --git a/yarn.lock b/yarn.lock index d6ee220198e..130e0bd7317 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6931,20 +6931,21 @@ __metadata: languageName: node linkType: hard -"@rtk-query/codegen-openapi@npm:^1.2.0": - version: 1.2.0 - resolution: "@rtk-query/codegen-openapi@npm:1.2.0" +"@rtk-query/codegen-openapi@npm:^2.0.0": + version: 2.0.0 + resolution: "@rtk-query/codegen-openapi@npm:2.0.0" dependencies: "@apidevtools/swagger-parser": "npm:^10.0.2" commander: "npm:^6.2.0" - oazapfts: "npm:^4.8.0" - prettier: "npm:^2.2.1" + lodash.camelcase: "npm:^4.3.0" + oazapfts: "npm:^6.1.0" + prettier: "npm:^3.2.5" semver: "npm:^7.3.5" swagger2openapi: "npm:^7.0.4" - typescript: "npm:^5.0.0" + typescript: "npm:^5.5.4" bin: - rtk-query-codegen-openapi: lib/bin/cli.js - checksum: 10/74b5d68b40322e6f30fff3f0250bccf378e5408e24bb47c109ad31e3c45a53c99d67b0ea0685ab0abcfc2e598eec9bdfc94aee241b61049ac862b77bd9780518 + rtk-query-codegen-openapi: lib/bin/cli.mjs + checksum: 10/c1dbab43e950c4defc1246034506c770a6a742aaf86a991c1cdbac0ac63cd2232c14bbe05d8c29421339b5e3aeb75740de3d709c5503f2af0b746e71b46caa87 languageName: node linkType: hard @@ -17549,7 +17550,7 @@ __metadata: "@react-types/shared": "npm:3.26.0" "@reduxjs/toolkit": "npm:2.5.0" "@rsdoctor/webpack-plugin": "npm:^0.4.6" - "@rtk-query/codegen-openapi": "npm:^1.2.0" + "@rtk-query/codegen-openapi": "npm:^2.0.0" "@rtsao/plugin-proposal-class-properties": "npm:7.0.1-patch.1" "@stylistic/eslint-plugin-ts": "npm:^2.9.0" "@swc/core": "npm:1.10.1" @@ -21129,6 +21130,13 @@ __metadata: languageName: node linkType: hard +"lodash.camelcase@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.camelcase@npm:4.3.0" + checksum: 10/c301cc379310441dc73cd6cebeb91fb254bea74e6ad3027f9346fc43b4174385153df420ffa521654e502fd34c40ef69ca4e7d40ee7129a99e06f306032bfc65 + languageName: node + linkType: hard + "lodash.debounce@npm:^4, lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8" @@ -22898,18 +22906,21 @@ __metadata: languageName: node linkType: hard -"oazapfts@npm:^4.8.0": - version: 4.12.0 - resolution: "oazapfts@npm:4.12.0" +"oazapfts@npm:^6.1.0": + version: 6.1.0 + resolution: "oazapfts@npm:6.1.0" dependencies: "@apidevtools/swagger-parser": "npm:^10.1.0" lodash: "npm:^4.17.21" minimist: "npm:^1.2.8" swagger2openapi: "npm:^7.0.8" - typescript: "npm:^5.2.2" + tapable: "npm:^2.2.1" + typescript: "npm:^5.4.5" + peerDependencies: + "@oazapfts/runtime": "*" bin: - oazapfts: lib/codegen/cli.js - checksum: 10/993d2cebfb2aab6178893e63039eda5968248684e02cf7e3df41e8fbc57d3a551c1c4a7b8bc3f1384f9a10557e9fd71717eeef8b1a38948a412ba46c5271f411 + oazapfts: cli.js + checksum: 10/804c3936702acf66bb9bcff6bdcdce2708783b31c83262d7097fb04a53d3720d96114cec345408cbf3f933f9c5d198815457853a07362796c6a6d5db7a31d150 languageName: node linkType: hard @@ -24370,7 +24381,7 @@ __metadata: languageName: node linkType: hard -"prettier@npm:3.4.2, prettier@npm:^3.1.1": +"prettier@npm:3.4.2, prettier@npm:^3.1.1, prettier@npm:^3.2.5": version: 3.4.2 resolution: "prettier@npm:3.4.2" bin: @@ -24379,7 +24390,7 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^2.2.1, prettier@npm:^2.3.2, prettier@npm:^2.8.7": +"prettier@npm:^2.3.2, prettier@npm:^2.8.7": version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: @@ -29718,7 +29729,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:5.5.4, typescript@npm:>=2.7, typescript@npm:>=3 < 6, typescript@npm:^5.0.0, typescript@npm:^5.0.4, typescript@npm:^5.2.2": +"typescript@npm:5.5.4": version: 5.5.4 resolution: "typescript@npm:5.5.4" bin: @@ -29728,7 +29739,17 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A5.5.4#optional!builtin, typescript@patch:typescript@npm%3A>=2.7#optional!builtin, typescript@patch:typescript@npm%3A>=3 < 6#optional!builtin, typescript@patch:typescript@npm%3A^5.0.0#optional!builtin, typescript@patch:typescript@npm%3A^5.0.4#optional!builtin, typescript@patch:typescript@npm%3A^5.2.2#optional!builtin": +"typescript@npm:>=2.7, typescript@npm:>=3 < 6, typescript@npm:^5.0.4, typescript@npm:^5.4.5, typescript@npm:^5.5.4": + version: 5.7.2 + resolution: "typescript@npm:5.7.2" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/4caa3904df69db9d4a8bedc31bafc1e19ffb7b24fbde2997a1633ae1398d0de5bdbf8daf602ccf3b23faddf1aeeb9b795223a2ed9c9a4fdcaf07bfde114a401a + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A5.5.4#optional!builtin": version: 5.5.4 resolution: "typescript@patch:typescript@npm%3A5.5.4#optional!builtin::version=5.5.4&hash=379a07" bin: @@ -29738,6 +29759,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A>=2.7#optional!builtin, typescript@patch:typescript@npm%3A>=3 < 6#optional!builtin, typescript@patch:typescript@npm%3A^5.0.4#optional!builtin, typescript@patch:typescript@npm%3A^5.4.5#optional!builtin, typescript@patch:typescript@npm%3A^5.5.4#optional!builtin": + version: 5.7.2 + resolution: "typescript@patch:typescript@npm%3A5.7.2#optional!builtin::version=5.7.2&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/d75ca10141afc64fd3474b41a8b082b640555bed388d237558aed64e5827ddadb48f90932c7f4205883f18f5bcab8b6a739a2cfac95855604b0dfeb34bc2f3eb + languageName: node + linkType: hard + "ua-parser-js@npm:^1.0.32": version: 1.0.33 resolution: "ua-parser-js@npm:1.0.33" From 987357327dde5039c6337338ef97761e5f4c7411 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Wed, 18 Dec 2024 17:16:34 +0100 Subject: [PATCH 03/32] Chore: Update authlib (#98191) --- go.mod | 2 +- go.sum | 4 ++-- pkg/apimachinery/go.mod | 2 +- pkg/apimachinery/go.sum | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 14e666a7584..8fcec037399 100644 --- a/go.mod +++ b/go.mod @@ -74,7 +74,7 @@ require ( github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad github.com/grafana/alerting v0.0.0-20241211182001-0f317eb6b2f7 // @grafana/alerting-backend - github.com/grafana/authlib v0.0.0-20241212110159-5ae10177a923 // @grafana/identity-access-team + github.com/grafana/authlib v0.0.0-20241218111825-19796465b76a // @grafana/identity-access-team github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 // @grafana/identity-access-team github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d // @grafana/dataviz-squad github.com/grafana/cuetsy v0.1.11 // @grafana/grafana-as-code diff --git a/go.sum b/go.sum index bc246f84c58..dc473a71b0c 100644 --- a/go.sum +++ b/go.sum @@ -2293,8 +2293,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/alerting v0.0.0-20241211182001-0f317eb6b2f7 h1:VGLUQ2mwzlF1NGwTxpSfv1RnuOsDlNh/NT5KRvhZ0sQ= github.com/grafana/alerting v0.0.0-20241211182001-0f317eb6b2f7/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU= -github.com/grafana/authlib v0.0.0-20241212110159-5ae10177a923 h1:M5WFasIUNOO48W8OpmryvEKkfnlmPBPG10uWiAmeB1k= -github.com/grafana/authlib v0.0.0-20241212110159-5ae10177a923/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI= +github.com/grafana/authlib v0.0.0-20241218111825-19796465b76a h1:1c672QJhHWnwWdouqVIqn38ArRhKRzkp3v71H+Tmqas= +github.com/grafana/authlib v0.0.0-20241218111825-19796465b76a/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI= github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk= github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A= github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk= diff --git a/pkg/apimachinery/go.mod b/pkg/apimachinery/go.mod index 47a66b72a3d..6f02e028f78 100644 --- a/pkg/apimachinery/go.mod +++ b/pkg/apimachinery/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/pkg/apimachinery go 1.23.1 require ( - github.com/grafana/authlib v0.0.0-20241212110159-5ae10177a923 // @grafana/identity-access-team + github.com/grafana/authlib v0.0.0-20241218111825-19796465b76a // @grafana/identity-access-team github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 // @grafana/identity-access-team github.com/stretchr/testify v1.10.0 k8s.io/apimachinery v0.31.3 diff --git a/pkg/apimachinery/go.sum b/pkg/apimachinery/go.sum index ad9935e3706..eecbe63046d 100644 --- a/pkg/apimachinery/go.sum +++ b/pkg/apimachinery/go.sum @@ -28,8 +28,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/grafana/authlib v0.0.0-20241212110159-5ae10177a923 h1:M5WFasIUNOO48W8OpmryvEKkfnlmPBPG10uWiAmeB1k= -github.com/grafana/authlib v0.0.0-20241212110159-5ae10177a923/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI= +github.com/grafana/authlib v0.0.0-20241218111825-19796465b76a h1:1c672QJhHWnwWdouqVIqn38ArRhKRzkp3v71H+Tmqas= +github.com/grafana/authlib v0.0.0-20241218111825-19796465b76a/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI= github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk= github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= From bff389aa4171780c3cf4eeb83f24104591b14ff3 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Wed, 18 Dec 2024 18:19:23 +0200 Subject: [PATCH 04/32] I18n: Download translations from Crowdin (#98187) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/de-DE/grafana.json | 5 +++++ public/locales/es-ES/grafana.json | 5 +++++ public/locales/fr-FR/grafana.json | 5 +++++ public/locales/pt-BR/grafana.json | 5 +++++ public/locales/zh-Hans/grafana.json | 5 +++++ 5 files changed, 25 insertions(+) diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index ed0caf51630..8efb1b74206 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -241,11 +241,13 @@ }, "common": { "cancel": "", + "clear-filters": "", "delete": "", "edit": "", "export": "", "export-all": "", "loading": "", + "search-by-matchers": "", "view": "" }, "contact-points": { @@ -327,6 +329,8 @@ } }, "mute-timings": { + "add-mute-timing": "", + "description": "", "save": "", "saving": "" }, @@ -349,6 +353,7 @@ "warning-1": "", "warning-2": "" }, + "filter-description": "", "generated-policies": "", "matchers": "", "metadata": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 6e3497e3f58..94fc44c782c 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -241,11 +241,13 @@ }, "common": { "cancel": "", + "clear-filters": "", "delete": "", "edit": "", "export": "", "export-all": "", "loading": "", + "search-by-matchers": "", "view": "" }, "contact-points": { @@ -327,6 +329,8 @@ } }, "mute-timings": { + "add-mute-timing": "", + "description": "", "save": "", "saving": "" }, @@ -349,6 +353,7 @@ "warning-1": "", "warning-2": "" }, + "filter-description": "", "generated-policies": "", "matchers": "", "metadata": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 2f6851a60af..a09f6fb3941 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -241,11 +241,13 @@ }, "common": { "cancel": "", + "clear-filters": "", "delete": "", "edit": "", "export": "", "export-all": "", "loading": "", + "search-by-matchers": "", "view": "" }, "contact-points": { @@ -327,6 +329,8 @@ } }, "mute-timings": { + "add-mute-timing": "", + "description": "", "save": "", "saving": "" }, @@ -349,6 +353,7 @@ "warning-1": "", "warning-2": "" }, + "filter-description": "", "generated-policies": "", "matchers": "", "metadata": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index b92410bba3d..6c5dc71854d 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -241,11 +241,13 @@ }, "common": { "cancel": "", + "clear-filters": "", "delete": "", "edit": "", "export": "", "export-all": "", "loading": "", + "search-by-matchers": "", "view": "" }, "contact-points": { @@ -327,6 +329,8 @@ } }, "mute-timings": { + "add-mute-timing": "", + "description": "", "save": "", "saving": "" }, @@ -349,6 +353,7 @@ "warning-1": "", "warning-2": "" }, + "filter-description": "", "generated-policies": "", "matchers": "", "metadata": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 4c015321d9c..c9f1313f029 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -241,11 +241,13 @@ }, "common": { "cancel": "", + "clear-filters": "", "delete": "", "edit": "", "export": "", "export-all": "", "loading": "", + "search-by-matchers": "", "view": "" }, "contact-points": { @@ -325,6 +327,8 @@ } }, "mute-timings": { + "add-mute-timing": "", + "description": "", "save": "", "saving": "" }, @@ -347,6 +351,7 @@ "warning-1": "", "warning-2": "" }, + "filter-description": "", "generated-policies": "", "matchers": "", "metadata": { From 34d7caef2d16be22aa794f9926bd7246b6c29a3c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Dec 2024 18:38:46 +0200 Subject: [PATCH 05/32] Update dependency react-i18next to v15 (#98209) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 16 ++++++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 06886425fc0..d565d15940c 100644 --- a/package.json +++ b/package.json @@ -372,7 +372,7 @@ "react-grid-layout": "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch", "react-highlight-words": "0.20.0", "react-hook-form": "^7.49.2", - "react-i18next": "^14.0.0", + "react-i18next": "^15.0.0", "react-inlinesvg": "4.1.5", "react-loading-skeleton": "3.5.0", "react-moveable": "0.56.0", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 17ebcfe40ed..be6ca8917e3 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -94,7 +94,7 @@ "react-dropzone": "14.3.5", "react-highlight-words": "0.20.0", "react-hook-form": "^7.49.2", - "react-i18next": "^14.0.0", + "react-i18next": "^15.0.0", "react-inlinesvg": "4.1.5", "react-loading-skeleton": "3.5.0", "react-router-dom-v5-compat": "^6.26.1", diff --git a/yarn.lock b/yarn.lock index 130e0bd7317..311a445da77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1438,7 +1438,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:7.26.0, @babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.14.0, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": +"@babel/runtime@npm:7.26.0, @babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.14.0, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": version: 7.26.0 resolution: "@babel/runtime@npm:7.26.0" dependencies: @@ -4044,7 +4044,7 @@ __metadata: react-dropzone: "npm:14.3.5" react-highlight-words: "npm:0.20.0" react-hook-form: "npm:^7.49.2" - react-i18next: "npm:^14.0.0" + react-i18next: "npm:^15.0.0" react-inlinesvg: "npm:4.1.5" react-loading-skeleton: "npm:3.5.0" react-router-dom-v5-compat: "npm:^6.26.1" @@ -17754,7 +17754,7 @@ __metadata: react-grid-layout: "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch" react-highlight-words: "npm:0.20.0" react-hook-form: "npm:^7.49.2" - react-i18next: "npm:^14.0.0" + react-i18next: "npm:^15.0.0" react-inlinesvg: "npm:4.1.5" react-loading-skeleton: "npm:3.5.0" react-moveable: "npm:0.56.0" @@ -25473,11 +25473,11 @@ __metadata: languageName: node linkType: hard -"react-i18next@npm:^14.0.0": - version: 14.1.2 - resolution: "react-i18next@npm:14.1.2" +"react-i18next@npm:^15.0.0": + version: 15.2.0 + resolution: "react-i18next@npm:15.2.0" dependencies: - "@babel/runtime": "npm:^7.23.9" + "@babel/runtime": "npm:^7.25.0" html-parse-stringify: "npm:^3.0.1" peerDependencies: i18next: ">= 23.2.3" @@ -25487,7 +25487,7 @@ __metadata: optional: true react-native: optional: true - checksum: 10/ec18d6d252380ae15cea32988e23f2c533fb0d629e04e7af157ae2ab73e9a030c400cee7efef5e10e3c781d7cb8f3e6284e2c70667785fa3efb9ac0e6caab6d7 + checksum: 10/9b2937f7beab763c494d55a801f21bfdbfe98e9509994c350d24fa404ded573f41e8607eeba290c686d5877d34f0ddefe48e9d6876720d5ed0e1243bcdd5dda6 languageName: node linkType: hard From 54e603e552e44ccbbd2bd5fcf27a6846cd3cf6ca Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Dec 2024 18:57:09 +0200 Subject: [PATCH 06/32] Update dependency rollup-plugin-node-externals to v8 (#98211) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/grafana-data/package.json | 2 +- packages/grafana-e2e-selectors/package.json | 2 +- packages/grafana-flamegraph/package.json | 2 +- packages/grafana-icons/package.json | 2 +- packages/grafana-prometheus/package.json | 2 +- packages/grafana-runtime/package.json | 2 +- packages/grafana-schema/package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 26 ++++++++++----------- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index d7647922dd1..6269f1c8bf7 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -77,7 +77,7 @@ "rollup": "^4.22.4", "rollup-plugin-dts": "^6.1.1", "rollup-plugin-esbuild": "6.1.1", - "rollup-plugin-node-externals": "^7.1.3", + "rollup-plugin-node-externals": "^8.0.0", "typescript": "5.5.4" }, "peerDependencies": { diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index 7eb8a8c004f..45d90d4b8e5 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -47,7 +47,7 @@ "rollup": "^4.22.4", "rollup-plugin-dts": "^6.1.1", "rollup-plugin-esbuild": "6.1.1", - "rollup-plugin-node-externals": "^7.1.3" + "rollup-plugin-node-externals": "^8.0.0" }, "dependencies": { "@grafana/tsconfig": "^2.0.0", diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index af725ed6992..783b4990f1f 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -79,7 +79,7 @@ "rollup": "^4.22.4", "rollup-plugin-dts": "^6.1.1", "rollup-plugin-esbuild": "6.1.1", - "rollup-plugin-node-externals": "^7.1.3", + "rollup-plugin-node-externals": "^8.0.0", "ts-jest": "29.2.5", "ts-node": "10.9.2", "typescript": "5.5.4" diff --git a/packages/grafana-icons/package.json b/packages/grafana-icons/package.json index a00c4cac732..e1e72d32be8 100644 --- a/packages/grafana-icons/package.json +++ b/packages/grafana-icons/package.json @@ -56,7 +56,7 @@ "rollup": "^4.22.4", "rollup-plugin-dts": "^6.1.1", "rollup-plugin-esbuild": "6.1.1", - "rollup-plugin-node-externals": "7.1.3", + "rollup-plugin-node-externals": "8.0.0", "ts-node": "10.9.2", "typescript": "5.5.4" }, diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 80b1ce50695..668cafc5905 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -128,7 +128,7 @@ "rollup": "^4.22.4", "rollup-plugin-dts": "^6.1.1", "rollup-plugin-esbuild": "6.1.1", - "rollup-plugin-node-externals": "^7.1.3", + "rollup-plugin-node-externals": "^8.0.0", "sass": "1.83.0", "sass-loader": "16.0.4", "style-loader": "4.0.0", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 82651ce078e..a4b702d5292 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -70,7 +70,7 @@ "rollup": "^4.22.4", "rollup-plugin-dts": "^6.1.1", "rollup-plugin-esbuild": "6.1.1", - "rollup-plugin-node-externals": "^7.1.3", + "rollup-plugin-node-externals": "^8.0.0", "rollup-plugin-sourcemaps": "0.6.3", "typescript": "5.5.4" }, diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index e9fd8e9985b..1ad470e2b5d 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -44,7 +44,7 @@ "rollup": "^4.22.4", "rollup-plugin-dts": "^6.1.1", "rollup-plugin-esbuild": "6.1.1", - "rollup-plugin-node-externals": "^7.1.3", + "rollup-plugin-node-externals": "^8.0.0", "typescript": "5.5.4" }, "dependencies": { diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index be6ca8917e3..115802bfe79 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -175,7 +175,7 @@ "rollup-plugin-copy": "3.5.0", "rollup-plugin-dts": "^6.1.1", "rollup-plugin-esbuild": "6.1.1", - "rollup-plugin-node-externals": "^7.1.3", + "rollup-plugin-node-externals": "^8.0.0", "rollup-plugin-svg-import": "3.0.0", "sass-loader": "16.0.4", "storybook": "^8.4.2", diff --git a/yarn.lock b/yarn.lock index 311a445da77..074c45f447c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3315,7 +3315,7 @@ __metadata: rollup: "npm:^4.22.4" rollup-plugin-dts: "npm:^6.1.1" rollup-plugin-esbuild: "npm:6.1.1" - rollup-plugin-node-externals: "npm:^7.1.3" + rollup-plugin-node-externals: "npm:^8.0.0" rxjs: "npm:7.8.1" string-hash: "npm:^1.1.3" tinycolor2: "npm:1.6.0" @@ -3342,7 +3342,7 @@ __metadata: rollup: "npm:^4.22.4" rollup-plugin-dts: "npm:^6.1.1" rollup-plugin-esbuild: "npm:6.1.1" - rollup-plugin-node-externals: "npm:^7.1.3" + rollup-plugin-node-externals: "npm:^8.0.0" semver: "npm:7.6.3" tslib: "npm:2.8.1" typescript: "npm:5.5.4" @@ -3486,7 +3486,7 @@ __metadata: rollup: "npm:^4.22.4" rollup-plugin-dts: "npm:^6.1.1" rollup-plugin-esbuild: "npm:6.1.1" - rollup-plugin-node-externals: "npm:^7.1.3" + rollup-plugin-node-externals: "npm:^8.0.0" tinycolor2: "npm:1.6.0" ts-jest: "npm:29.2.5" ts-node: "npm:10.9.2" @@ -3727,7 +3727,7 @@ __metadata: rollup: "npm:^4.22.4" rollup-plugin-dts: "npm:^6.1.1" rollup-plugin-esbuild: "npm:6.1.1" - rollup-plugin-node-externals: "npm:^7.1.3" + rollup-plugin-node-externals: "npm:^8.0.0" rxjs: "npm:7.8.1" sass: "npm:1.83.0" sass-loader: "npm:16.0.4" @@ -3779,7 +3779,7 @@ __metadata: rollup: "npm:^4.22.4" rollup-plugin-dts: "npm:^6.1.1" rollup-plugin-esbuild: "npm:6.1.1" - rollup-plugin-node-externals: "npm:^7.1.3" + rollup-plugin-node-externals: "npm:^8.0.0" rollup-plugin-sourcemaps: "npm:0.6.3" rxjs: "npm:7.8.1" tslib: "npm:2.8.1" @@ -3816,7 +3816,7 @@ __metadata: rollup: "npm:^4.22.4" rollup-plugin-dts: "npm:^6.1.1" rollup-plugin-esbuild: "npm:6.1.1" - rollup-plugin-node-externals: "npm:7.1.3" + rollup-plugin-node-externals: "npm:8.0.0" ts-node: "npm:10.9.2" typescript: "npm:5.5.4" peerDependencies: @@ -3879,7 +3879,7 @@ __metadata: rollup: "npm:^4.22.4" rollup-plugin-dts: "npm:^6.1.1" rollup-plugin-esbuild: "npm:6.1.1" - rollup-plugin-node-externals: "npm:^7.1.3" + rollup-plugin-node-externals: "npm:^8.0.0" tslib: "npm:2.8.1" typescript: "npm:5.5.4" languageName: unknown @@ -4059,7 +4059,7 @@ __metadata: rollup-plugin-copy: "npm:3.5.0" rollup-plugin-dts: "npm:^6.1.1" rollup-plugin-esbuild: "npm:6.1.1" - rollup-plugin-node-externals: "npm:^7.1.3" + rollup-plugin-node-externals: "npm:^8.0.0" rollup-plugin-svg-import: "npm:3.0.0" rxjs: "npm:7.8.1" sass-loader: "npm:16.0.4" @@ -26799,12 +26799,12 @@ __metadata: languageName: node linkType: hard -"rollup-plugin-node-externals@npm:7.1.3, rollup-plugin-node-externals@npm:^7.1.3": - version: 7.1.3 - resolution: "rollup-plugin-node-externals@npm:7.1.3" +"rollup-plugin-node-externals@npm:8.0.0, rollup-plugin-node-externals@npm:^8.0.0": + version: 8.0.0 + resolution: "rollup-plugin-node-externals@npm:8.0.0" peerDependencies: - rollup: ^3.0.0 || ^4.0.0 - checksum: 10/4e8d38ebc3c8a29cb72d11a73f97ba810a4560b5b0f0be445ba32180875073ce4752a9ea4d0b658717ddae642d314738047677e5c818b1893f3d715d18fe413c + rollup: ^4.0.0 + checksum: 10/ef5565e8fdba4acbba81c4b214f789e03d3a3d049cbbe372679151c4f749a21a7f51d8448eff82b0b1bbbe5d2d64862cd292795e0f92e1fcad2afc9954a6fbdd languageName: node linkType: hard From 4837585cab0fd84184a8c6f5d6891f442a2b95f1 Mon Sep 17 00:00:00 2001 From: owensmallwood Date: Wed, 18 Dec 2024 11:00:54 -0600 Subject: [PATCH 07/32] Unified Storage Search: Add sprinkles (#97820) * Wire up sprinkles to oss and enterprise. Fetching sprinkles not implemented yet. * Adds wireset for initializing document builders. Had to init it when creating the service to avoid cyclical imports. * updates to int64 for stats * adds config for sprinklesApiServer and gets sprinkles from there when its present * add comment for later * adds feature toggle for sprinkles. returns empty results when flag not enabled. * adds unified storage config setting for sprinkles apiserver page limit * fixes bug where dashboard uid was not getting set * when creating dashboard summary, use metadata.name as the dashboard uid * cleans up wire. use existing oss and enterprise sets to generate doc builders * remove old wireset * fix linter - adds missing arg for doc builders * update dashboard stats in tests * updates test-data dashboards * log a warning instead of returning an error if we can't get sprinkles for a namespace * dont read uid from dashboard json --- .../src/types/featureToggles.gen.ts | 1 + pkg/server/module_server.go | 6 ++++- pkg/server/wire.go | 7 +++++- pkg/server/wireexts_oss.go | 4 +++ pkg/services/featuremgmt/registry.go | 8 ++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 +++ pkg/services/featuremgmt/toggles_gen.json | 14 +++++++++++ pkg/setting/setting.go | 14 ++++++----- pkg/setting/setting_unified_storage.go | 2 ++ pkg/storage/unified/search/bleve_test.go | 2 +- pkg/storage/unified/search/dashboard.go | 25 ++++++++----------- pkg/storage/unified/search/dashboard_stats.go | 15 +++++++++++ pkg/storage/unified/search/document.go | 22 +++++++++++++--- pkg/storage/unified/search/document_test.go | 4 +-- pkg/storage/unified/sql/service.go | 13 +++++----- .../unified/sql/test/integration_test.go | 2 +- pkg/tests/testinfra/testinfra.go | 2 +- 18 files changed, 108 insertions(+), 38 deletions(-) create mode 100644 pkg/storage/unified/search/dashboard_stats.go diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c223d234a9e..169a99dfc6f 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -220,6 +220,7 @@ export interface FeatureToggles { useSessionStorageForRedirection?: boolean; rolePickerDrawer?: boolean; unifiedStorageSearch?: boolean; + unifiedStorageSearchSprinkles?: boolean; pluginsSriChecks?: boolean; unifiedStorageBigObjectsSupport?: boolean; timeRangeProvider?: boolean; diff --git a/pkg/server/module_server.go b/pkg/server/module_server.go index 366f2413cdd..f20225cba5b 100644 --- a/pkg/server/module_server.go +++ b/pkg/server/module_server.go @@ -131,7 +131,11 @@ func (s *ModuleServer) Run() error { //} m.RegisterModule(modules.StorageServer, func() (services.Service, error) { - return sql.ProvideUnifiedStorageGrpcService(s.cfg, s.features, nil, s.log, nil) + docBuilders, err := InitializeDocumentBuilders(s.cfg) + if err != nil { + return nil, err + } + return sql.ProvideUnifiedStorageGrpcService(s.cfg, s.features, nil, s.log, nil, docBuilders) }) m.RegisterModule(modules.ZanzanaServer, func() (services.Service, error) { diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 54026bfd583..97e13c79e49 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -8,6 +8,7 @@ package server import ( "github.com/google/wire" + "github.com/grafana/grafana/pkg/storage/unified/resource" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" @@ -229,7 +230,6 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, - unifiedsearch.ProvideDocumentBuilders, search.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, @@ -475,3 +475,8 @@ func InitializeAPIServerFactory() (standalone.APIServerFactory, error) { wire.Build(wireExtsStandaloneAPIServerSet) return &standalone.NoOpAPIServerFactory{}, nil // Wire will replace this with a real interface } + +func InitializeDocumentBuilders(cfg *setting.Cfg) (resource.DocumentBuilderSupplier, error) { + wire.Build(wireExtsSet) + return &unifiedsearch.StandardDocumentBuilders{}, nil +} diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 7f9b5756da8..6673823f8bb 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -6,6 +6,7 @@ package server import ( "github.com/google/wire" + search2 "github.com/grafana/grafana/pkg/storage/unified/search" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/plugins" @@ -106,6 +107,9 @@ var wireExtsBasicSet = wire.NewSet( wire.Bind(new(auth.IDSigner), new(*idimpl.LocalSigner)), manager.ProvideInstaller, wire.Bind(new(plugins.Installer), new(*manager.PluginInstaller)), + search2.ProvideDashboardStats, + wire.Bind(new(search2.DashboardStats), new(*search2.OssDashboardStats)), + search2.ProvideDocumentBuilders, ) var wireExtsSet = wire.NewSet( diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index bdf32497afc..e79a97df92f 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1525,6 +1525,14 @@ var ( HideFromDocs: true, HideFromAdminPage: true, }, + { + Name: "unifiedStorageSearchSprinkles", + Description: "Enable sprinkles on unified storage search", + Stage: FeatureStageExperimental, + Owner: grafanaSearchAndStorageSquad, + HideFromDocs: true, + HideFromAdminPage: true, + }, { Name: "pluginsSriChecks", Description: "Enables SRI checks for plugin assets", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 6d861b6c35c..84416308b00 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -201,6 +201,7 @@ improvedExternalSessionHandling,experimental,@grafana/identity-access-team,false useSessionStorageForRedirection,GA,@grafana/identity-access-team,false,false,false rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false +unifiedStorageSearchSprinkles,experimental,@grafana/search-and-storage,false,false,false pluginsSriChecks,experimental,@grafana/plugins-platform-backend,false,false,false unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index cc91effe7a1..42843a4cf75 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -815,6 +815,10 @@ const ( // Enable unified storage search FlagUnifiedStorageSearch = "unifiedStorageSearch" + // FlagUnifiedStorageSearchSprinkles + // Enable sprinkles on unified storage search + FlagUnifiedStorageSearchSprinkles = "unifiedStorageSearchSprinkles" + // FlagPluginsSriChecks // Enables SRI checks for plugin assets FlagPluginsSriChecks = "pluginsSriChecks" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 6a46402666d..7a4396aad85 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3618,6 +3618,20 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "unifiedStorageSearchSprinkles", + "resourceVersion": "1734123247356", + "creationTimestamp": "2024-12-13T20:54:07Z" + }, + "spec": { + "description": "Enable sprinkles on unified storage search", + "stage": "experimental", + "codeowner": "@grafana/search-and-storage", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "useSeessionStorageForRedirection", diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 092f03e8094..39d92b5f532 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -523,12 +523,14 @@ type Cfg struct { ShortLinkExpiration int // Unified Storage - UnifiedStorage map[string]UnifiedStorageConfig - IndexPath string - IndexWorkers int - IndexMaxBatchSize int - IndexFileThreshold int - IndexMinCount int + UnifiedStorage map[string]UnifiedStorageConfig + IndexPath string + IndexWorkers int + IndexMaxBatchSize int + IndexFileThreshold int + IndexMinCount int + SprinklesApiServer string + SprinklesApiServerPageLimit int } type UnifiedStorageConfig struct { diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 610bb843d2f..9e3127b8277 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -52,4 +52,6 @@ func (cfg *Cfg) setUnifiedStorageConfig() { cfg.IndexMaxBatchSize = section.Key("index_max_batch_size").MustInt(100) cfg.IndexFileThreshold = section.Key("index_file_threshold").MustInt(10) cfg.IndexMinCount = section.Key("index_min_count").MustInt(1) + cfg.SprinklesApiServer = section.Key("sprinkles_api_server").String() + cfg.SprinklesApiServerPageLimit = section.Key("sprinkles_api_server_page_limit").MustInt(100) } diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index 91f29979827..c99dff15833 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -49,7 +49,7 @@ func TestBleveBackend(t *testing.T) { return &DashboardDocumentBuilder{ Namespace: namespace, Blob: blob, - Stats: NewDashboardStatsLookup(nil), // empty stats + Stats: make(map[string]map[string]int64), // empty stats DatasourceLookup: dashboard.CreateDatasourceLookup([]*dashboard.DatasourceQueryResult{{}}), }, nil }) diff --git a/pkg/storage/unified/search/dashboard.go b/pkg/storage/unified/search/dashboard.go index edf13abc1f6..d4cd334ac04 100644 --- a/pkg/storage/unified/search/dashboard.go +++ b/pkg/storage/unified/search/dashboard.go @@ -75,7 +75,7 @@ func DashboardBuilder(namespaced resource.NamespacedDocumentSupplier) (resource. return &DashboardDocumentBuilder{ Namespace: namespace, Blob: blob, - Stats: NewDashboardStatsLookup(nil), + Stats: nil, DatasourceLookup: dashboard.CreateDatasourceLookup([]*dashboard.DatasourceQueryResult{ // empty values (does not resolve anything) }), @@ -94,8 +94,8 @@ type DashboardDocumentBuilder struct { Namespace string // Cached stats for this namespace - // TODO, load this from apiserver request - Stats DashboardStatsLookup + // maps dashboard UID to stats + Stats map[string]map[string]int64 // data source lookup DatasourceLookup dashboard.DatasourceLookup @@ -104,17 +104,12 @@ type DashboardDocumentBuilder struct { Blob resource.BlobSupport } -type DashboardStatsLookup = func(ctx context.Context, uid string) map[string]int64 - -func NewDashboardStatsLookup(stats map[string]map[string]int64) DashboardStatsLookup { - return func(ctx context.Context, uid string) map[string]int64 { - if stats == nil { - return nil - } - return stats[uid] - } +type DashboardStats interface { + GetStats(ctx context.Context, namespace string) (map[string]map[string]int64, error) } +type DashboardStatsLookup = func(ctx context.Context, uid string) map[string]int64 + var _ resource.DocumentBuilder = &DashboardDocumentBuilder{} func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resource.ResourceKey, rv int64, value []byte) (*resource.IndexableDocument, error) { @@ -150,6 +145,9 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou return nil, err } + // metadata name is the dashboard uid + summary.UID = obj.GetName() + doc := resource.NewIndexableDocument(key, rv, obj) doc.Title = summary.Title doc.Description = summary.Description @@ -211,8 +209,7 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou } // Add the stats fields - stats := s.Stats(ctx, key.Name) // summary.UID - for k, v := range stats { + for k, v := range s.Stats[summary.UID] { doc.Fields[k] = v } diff --git a/pkg/storage/unified/search/dashboard_stats.go b/pkg/storage/unified/search/dashboard_stats.go new file mode 100644 index 00000000000..c82db38110e --- /dev/null +++ b/pkg/storage/unified/search/dashboard_stats.go @@ -0,0 +1,15 @@ +package search + +import ( + "context" +) + +type OssDashboardStats struct{} + +func ProvideDashboardStats() *OssDashboardStats { + return &OssDashboardStats{} +} + +func (s *OssDashboardStats) GetStats(ctx context.Context, namespace string) (map[string]map[string]int64, error) { + return nil, nil +} diff --git a/pkg/storage/unified/search/document.go b/pkg/storage/unified/search/document.go index 78836bef06e..75bc88b6204 100644 --- a/pkg/storage/unified/search/document.go +++ b/pkg/storage/unified/search/document.go @@ -5,23 +5,25 @@ import ( "github.com/grafana/authlib/claims" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/store/kind/dashboard" "github.com/grafana/grafana/pkg/storage/unified/resource" ) // The default list of open source document builders type StandardDocumentBuilders struct { - sql db.DB + sql db.DB + sprinkles DashboardStats } // Hooked up so wire can fill in different sprinkles -func ProvideDocumentBuilders(sql db.DB) resource.DocumentBuilderSupplier { - return &StandardDocumentBuilders{sql} +func ProvideDocumentBuilders(sql db.DB, sprinkles DashboardStats) resource.DocumentBuilderSupplier { + return &StandardDocumentBuilders{sql, sprinkles} } func (s *StandardDocumentBuilders) GetDocumentBuilders() ([]resource.DocumentBuilderInfo, error) { dashboards, err := DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) { - stats := NewDashboardStatsLookup(nil) // empty stats + logger := log.New("dashboard_builder", "namespace", namespace) dsinfo := []*dashboard.DatasourceQueryResult{{}} ns, err := claims.ParseNamespace(namespace) if err != nil && s.sql != nil { @@ -43,6 +45,18 @@ func (s *StandardDocumentBuilders) GetDocumentBuilders() ([]resource.DocumentBui dsinfo = append(dsinfo, info) } } + + // Fetch dashboard sprinkles for the namespace + // This could take a while if namespace has a lot of dashboards + var stats map[string]map[string]int64 + if s.sprinkles != nil { + stats, err = s.sprinkles.GetStats(ctx, namespace) + if err != nil { + // only log a warning. Don't need to fail the indexer if we can't get sprinkles + logger.Warn("Failed to get sprinkles", "error", err) + } + } + return &DashboardDocumentBuilder{ Namespace: namespace, Blob: blob, diff --git a/pkg/storage/unified/search/document_test.go b/pkg/storage/unified/search/document_test.go index 400e37682b8..04f9637cb77 100644 --- a/pkg/storage/unified/search/document_test.go +++ b/pkg/storage/unified/search/document_test.go @@ -56,12 +56,12 @@ func TestDashboardDocumentBuilder(t *testing.T) { return &DashboardDocumentBuilder{ Namespace: namespace, Blob: blob, - Stats: NewDashboardStatsLookup(map[string]map[string]int64{ + Stats: map[string]map[string]int64{ "aaa": { DASHBOARD_ERRORS_LAST_1_DAYS: 1, DASHBOARD_ERRORS_LAST_7_DAYS: 1, }, - }), + }, DatasourceLookup: dashboard.CreateDatasourceLookup([]*dashboard.DatasourceQueryResult{{ Name: "TheDisplayName", // used to be the unique ID! Type: "my-custom-plugin", diff --git a/pkg/storage/unified/sql/service.go b/pkg/storage/unified/sql/service.go index 31354049f28..4397a48b6d4 100644 --- a/pkg/storage/unified/sql/service.go +++ b/pkg/storage/unified/sql/service.go @@ -20,7 +20,6 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resource/grpc" - "github.com/grafana/grafana/pkg/storage/unified/search" ) var ( @@ -51,6 +50,8 @@ type service struct { log log.Logger reg prometheus.Registerer + + docBuilders resource.DocumentBuilderSupplier } func ProvideUnifiedStorageGrpcService( @@ -59,6 +60,7 @@ func ProvideUnifiedStorageGrpcService( db infraDB.DB, log log.Logger, reg prometheus.Registerer, + docBuilders resource.DocumentBuilderSupplier, ) (UnifiedStorageGrpcService, error) { tracingCfg, err := tracing.ProvideTracingConfig(cfg) if err != nil { @@ -78,7 +80,7 @@ func ProvideUnifiedStorageGrpcService( // FIXME: This is a temporary solution while we are migrating to the new authn interceptor // grpcutils.NewGrpcAuthenticator should be used instead. - authn, err := grpcutils.NewGrpcAuthenticatorWithFallback(cfg, prometheus.DefaultRegisterer, tracing, &grpc.Authenticator{}) + authn, err := grpcutils.NewGrpcAuthenticatorWithFallback(cfg, reg, tracing, &grpc.Authenticator{}) if err != nil { return nil, err } @@ -92,6 +94,7 @@ func ProvideUnifiedStorageGrpcService( db: db, log: log, reg: reg, + docBuilders: docBuilders, } // This will be used when running as a dskit service @@ -106,11 +109,7 @@ func (s *service) start(ctx context.Context) error { return err } - // TODO, for standalone this will need to be started from enterprise - // Connecting to the correct remote services (cloudconfig for DS info and usage stats) - docs := search.ProvideDocumentBuilders(nil) - - server, err := NewResourceServer(ctx, s.db, s.cfg, s.features, docs, s.tracing, s.reg, authzClient) + server, err := NewResourceServer(ctx, s.db, s.cfg, s.features, s.docBuilders, s.tracing, s.reg, authzClient) if err != nil { return err } diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index a4423cd5fd9..03187e69dfe 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -366,7 +366,7 @@ func TestClientServer(t *testing.T) { features := featuremgmt.WithFeatures() - svc, err := sql.ProvideUnifiedStorageGrpcService(cfg, features, dbstore, nil, prometheus.NewPedanticRegistry()) + svc, err := sql.ProvideUnifiedStorageGrpcService(cfg, features, dbstore, nil, prometheus.NewPedanticRegistry(), nil) require.NoError(t, err) var client resource.ResourceStoreClient diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 3120ee859a1..882bb710f17 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -104,7 +104,7 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes var storage sql.UnifiedStorageGrpcService if runstore { storage, err = sql.ProvideUnifiedStorageGrpcService(env.Cfg, env.FeatureToggles, env.SQLStore, - env.Cfg.Logger, prometheus.NewPedanticRegistry()) + env.Cfg.Logger, prometheus.NewPedanticRegistry(), nil) require.NoError(t, err) ctx := context.Background() err = storage.StartAsync(ctx) From dc52d1b252aa58c958886faa55efcdae2cb5e7f5 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:40:39 -0500 Subject: [PATCH 08/32] Docs: Gauge visualization refactor (#98143) --- .../visualizations/gauge/index.md | 128 +++++++----------- 1 file changed, 47 insertions(+), 81 deletions(-) diff --git a/docs/sources/panels-visualizations/visualizations/gauge/index.md b/docs/sources/panels-visualizations/visualizations/gauge/index.md index 0c8553138d9..bfcd1c89549 100644 --- a/docs/sources/panels-visualizations/visualizations/gauge/index.md +++ b/docs/sources/panels-visualizations/visualizations/gauge/index.md @@ -27,7 +27,7 @@ refs: Gauges are single-value visualizations that allow you to quickly visualize where a value falls within a defined or calculated min and max range. With repeat options, you can display multiple gauges, each corresponding to a different series, column, or row. -{{< figure src="/static/img/docs/v66/gauge_panel_cover.png" max-width="1025px" alt="A gauge visualization">}} +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-gauge-visualization-v11.4.png" alt="A gauge visualization">}} You can use gauges if you need to track: @@ -35,11 +35,11 @@ You can use gauges if you need to track: - How full a piece of equipment is - How fast a vehicle is moving within a set of limits - Network latency -- Equipment state with setpoint and alarm thresholds +- Equipment state with set point and alarm thresholds - CPU consumption (0-100%) - RAM availability -## Configure a time series visualization +## Configure a gauge visualization The following video provides beginner steps for creating gauge panels. You'll learn the data requirements and caveats, special customizations, and much more: @@ -57,13 +57,13 @@ To create a gauge visualization you need a dataset containing at least one numer | --------- | ---------- | | MyGauge | 5 | -![Gauge with single numeric value](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.2-gauge-example1.png 'Gauge with single numeric value') +![Gauge with single numeric value](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.2-gauge-example1.png) This dataset generates a visualization with one empty gauge showing the numeric value. This is because the gauge visualization automatically defines the upper and lower range from the minimum and maximum values in the dataset. This dataset has only one value, so it’s set as both minimum and maximum. If you only have one value, but you want to define a different minimum and maximum, you can set them manually in the [Standard options](#standard-options) settings to generate a more typical looking gauge. -![Gauge with single numeric value and hardcoded max and min](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.2-gauge-example2.png 'Gauge with single numeric value and hardcoded max-min') +![Gauge with single numeric value and hardcoded max and min](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.2-gauge-example2.png) ### Example - One row, multiple values @@ -73,7 +73,7 @@ The gauge visualization can support multiple fields in a dataset. -Choose how Grafana displays your data. +| Option | Description | +| ------ | ----------- | +| Show | Set how Grafana displays your data. Choose from:
  • **Calculate** - Show a calculated value based on all rows.
  • **All values** - Show a separate value for every row. If you select this option, then you can also limit the number of rows to display.
| +| Calculation | If you chose **Calculate** as your **Show** option, select a reducer function that Grafana will use to reduce many fields to a single value. For a list of available calculations, refer to [Calculation types](ref:calculation-types). | +| Limit | If you chose **All values** as your **Show** option, enter the maximum number of rows to display. The default is 5,000. | +| Fields | Select the fields display in the panel. | -#### Calculate + -Show a calculated value based on all rows. - -- **Calculation -** Select a reducer function that Grafana will use to reduce many fields to a single value. For a list of available calculations, refer to [Calculation types](ref:calculation-types). -- **Fields -** Select the fields display in the panel. - -#### All values - -Show a separate stat for every row. If you select this option, then you can also limit the number of rows to display. - -- **Limit -** The maximum number of rows to display. Default is 5,000. -- **Fields -** Select the fields display in the panel. - -## Gauge +### Gauge options Adjust how the gauge is displayed. -### Orientation + -Choose a stacking direction. +| Option | Description | +| ------ | ----------- | +| Orientation | Choose a stacking direction:
  • **Auto** - Gauges display in rows and columns.
  • **Horizontal** - Gauges display top to bottom.
  • **Vertical** - Gauges display left to right.
| +| Show threshold labels | Controls if threshold values are shown. | +| [Show threshold markers](#show-threshold-markers) | Controls if a threshold band is shown outside the inner gauge value band. | +| Gauge size | Choose a gauge size mode:
  • **Auto** - Grafana determines the best gauge size.
  • **Manual** - Manually configure the gauge size.
This option only applies when **Orientation** is set to **Horizontal** or **Vertical**. | +| Min width | Set the minimum width of vertically-oriented gauges. If you set a minimum width, the x-axis scrollbar is automatically displayed when there's a large amount of data. This option only applies when **Gauge size** is set to **Manual**. | +| Min height | Set the minimum height of horizontally-oriented gauges. If you set a minimum height, the y-axis scrollbar is automatically displayed when there's a large amount of data. This option only applies when **Gauge size** is set to **Manual**. | +| Neutral | Set the starting value from which every gauge will be filled. | -- **Auto -** Gauges display in rows and columns. -- **Horizontal -** Gauges display top to bottom. -- **Vertical -** Gauges display left to right. + -### Show threshold labels +#### Show threshold markers -Controls if threshold values are shown. +Controls if a threshold band is shown around the inner gauge value band. -### Show threshold markers +![Gauge viz with multiple rows and columns of numeric values showing all the values and thresholds defined for 0-6-11](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.2-gauge-example8.png) -Controls if a threshold band is shown outside the inner gauge value band. - -### Gauge size - -Choose a gauge size mode. - -- **Auto -** Grafana determines the best gauge size. -- **Manual -** Manually configure the gauge size. - -### Min width - -Set the minimum width of vertically-oriented gauges. - -If you set a minimum width, the x-axis scrollbar is automatically displayed when there's a large amount of data. - -{{% admonition type="note" %}} -This option only applies when gauge size is set to manual. -{{% /admonition %}} - -### Min height - -Set the minimum height of horizontally-oriented gauges. - -If you set a minimum height, the y-axis scrollbar is automatically displayed when there's a large amount of data. - -{{% admonition type="note" %}} -This option only applies when gauge size is set to manual. -{{% /admonition %}} - -### Neutral - -Set the starting value from which every gauge will be filled. - -## Text size +### Text size Adjust the sizes of the gauge text. -- **Title -** Enter a numeric value for the gauge title size. -- **Value -** Enter a numeric value for the gauge value size. +- **Title** - Enter a numeric value for the gauge title size. +- **Value** - Enter a numeric value for the gauge value size. -## Standard options +### Standard options {{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="" >}} -## Data links +### Data links {{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}} -## Value mappings +### Value mappings {{< docs/shared lookup="visualizations/value-mappings-options.md" source="grafana" version="" >}} -## Thresholds +### Thresholds {{< docs/shared lookup="visualizations/thresholds-options-2.md" source="grafana" version="" >}} -Last, gauge colors and thresholds (the outer bar markers) of the gauge can be configured as described above. - -![Gauge viz with multiple rows and columns of numeric values showing all the values and thresholds defined for 0-6-11](/media/docs/grafana/panels-visualizations/screenshot-grafana-12.2-gauge-example8.png 'Gauge viz with multiple rows and columns of numeric values showing all the values and thresholds defined for 0-6-11') - -## Field overrides +### Field overrides {{< docs/shared lookup="visualizations/overrides-options.md" source="grafana" version="" >}} From 52673ad39056d6cbde3df73fccfe6f70aa07c1f8 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Wed, 18 Dec 2024 18:03:47 +0000 Subject: [PATCH 09/32] Log rows: Refactor as functional components and remove render preview (#98025) * LogRow: migrate to functional component * LogRowContextModal: remove dependency between test cases * LogRows: turn into functional component * LogRows: try GhostRow, preview, and move event listeners to the parent * LogRows: adjust preview size for few log rows * Explore: optimize inline and derived props * Remove log * Logs: restore set displayed fields * Refactor props that cause re-renders * Unmemoize shouldShowMenu * Refactor to PreviewLogRow and add preview to DisplayedFields * Refactor moving listeners to parent * Update unit tests * LogRows: update preview size to twice screen height * Revert change * Update unit test * Update utils test * Update logsPanel unit test * Improve permalinking * LogRows: decrease preview size * PreviewRow: render log entry * Prettier * LogRow: update unit test * Update missing props * Fix logs volume toggling * Destructure prop --- public/app/features/explore/Explore.tsx | 12 +- public/app/features/explore/Logs/Logs.tsx | 330 ++++++------ .../features/explore/Logs/LogsContainer.tsx | 16 +- .../features/explore/Logs/LogsSamplePanel.tsx | 1 + .../logs/components/InfiniteScroll.test.tsx | 1 + .../logs/components/InfiniteScroll.tsx | 2 +- .../features/logs/components/LogRow.test.tsx | 16 +- .../app/features/logs/components/LogRow.tsx | 495 ++++++++---------- .../logs/components/LogRowMessage.tsx | 2 +- .../LogRowMessageDisplayedFields.tsx | 18 +- .../features/logs/components/LogRows.test.tsx | 86 +-- .../app/features/logs/components/LogRows.tsx | 371 ++++++------- .../logs/components/PreviewLogRow.tsx | 29 + .../log-context/LogRowContextModal.test.tsx | 51 +- .../log-context/LogRowContextModal.tsx | 3 + public/app/features/logs/utils.test.ts | 33 +- public/app/features/logs/utils.ts | 31 +- public/app/plugins/panel/logs/LogsPanel.tsx | 5 +- 18 files changed, 732 insertions(+), 770 deletions(-) create mode 100644 public/app/features/logs/components/PreviewLogRow.tsx diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 48aed8a18ec..de38f8019fa 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -323,6 +323,10 @@ export class Explore extends PureComponent { }; }; + onPinLineCallback = () => { + this.setState({ contentOutlineVisible: true }); + }; + renderEmptyState(exploreContainerStyles: string) { return (
@@ -414,6 +418,8 @@ export class Explore extends PureComponent { ); } + splitOpenFnLogs = this.onSplitOpen('logs'); + renderLogsPanel(width: number) { const { exploreId, syncedTimes, theme, queryResponse } = this.props; const spacing = parseInt(theme.spacing(2).slice(0, -2), 10); @@ -435,14 +441,12 @@ export class Explore extends PureComponent { onStartScanning={this.onStartScanning} onStopScanning={this.onStopScanning} eventBus={this.logsEventBus} - splitOpenFn={this.onSplitOpen('logs')} + splitOpenFn={this.splitOpenFnLogs} scrollElement={this.scrollElement} isFilterLabelActive={this.isFilterLabelActive} onClickFilterString={this.onClickFilterString} onClickFilterOutString={this.onClickFilterOutString} - onPinLineCallback={() => { - this.setState({ contentOutlineVisible: true }); - }} + onPinLineCallback={this.onPinLineCallback} /> ); diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 86b4dd43a1f..3a026431656 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -1,7 +1,6 @@ import { css, cx } from '@emotion/css'; import { capitalize, groupBy } from 'lodash'; -import memoizeOne from 'memoize-one'; -import { useCallback, useEffect, useState, useRef } from 'react'; +import { useCallback, useEffect, useState, useRef, useMemo } from 'react'; import * as React from 'react'; import { usePrevious, useUnmount } from 'react-use'; @@ -189,6 +188,8 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { loadMoreLogs, panelState, eventBus, + onPinLineCallback, + scrollElement, } = props; const [showLabels, setShowLabels] = useState(store.getBool(SETTINGS_KEYS.showLabels, false)); const [showTime, setShowTime] = useState(store.getBool(SETTINGS_KEYS.showTime, true)); @@ -210,8 +211,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const [visualisationType, setVisualisationType] = useState( panelState?.logs?.visualisationType ?? getDefaultVisualisationType() ); - const [scrollIntoView, setScrollIntoView] = useState<((element: HTMLElement) => void) | undefined>(undefined); - const logsContainerRef = useRef(undefined); + const logsContainerRef = useRef(null); const dispatch = useDispatch(); const previousLoading = usePrevious(loading); @@ -230,9 +230,13 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { // Get pinned log lines const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root'); - const pinnedLogs = logsParent?.children - ?.filter((outlines) => outlines.title === PINNED_LOGS_TITLE) - .map((pinnedLogs) => pinnedLogs.id); + const pinnedLogs = useMemo( + () => + logsParent?.children + ?.filter((outlines) => outlines.title === PINNED_LOGS_TITLE) + .map((pinnedLogs) => pinnedLogs.id), + [logsParent?.children] + ); const getPinnedLogsCount = useCallback(() => { const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root'); @@ -433,39 +437,28 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { [props.eventBus] ); - const onLogsContainerRef = useCallback( - (node: HTMLDivElement) => { - logsContainerRef.current = node; - - // In theory this should be just a function passed down to LogRows but: - // - LogRow.componentDidMount which calls scrollIntoView is called BEFORE the logsContainerRef is set - // - the if check below if (logsContainerRef.current) was falsy and scrolling doesn't happen - // - and LogRow.scrollToLogRow marks the line as scrolled anyway (and won't perform scrolling when the ref is set) - // - see more details in https://github.com/facebook/react/issues/29897 - // We can change it once LogRow is converted into a functional component - setScrollIntoView(() => (element: HTMLElement) => { - if (config.featureToggles.logsInfiniteScrolling) { - if (logsContainerRef.current) { - topLogsRef.current?.scrollIntoView(); - logsContainerRef.current.scroll({ - behavior: 'smooth', - top: logsContainerRef.current.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, - }); - } - - return; - } - const scrollElement = props.scrollElement; - - if (scrollElement) { - scrollElement.scroll({ + const scrollIntoView = useCallback( + (element: HTMLElement) => { + if (config.featureToggles.logsInfiniteScrolling) { + if (logsContainerRef.current) { + topLogsRef.current?.scrollIntoView(); + logsContainerRef.current.scroll({ behavior: 'smooth', - top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, + top: logsContainerRef.current.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, }); } - }); + + return; + } + + if (scrollElement) { + scrollElement.scroll({ + behavior: 'smooth', + top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, + }); + } }, - [props.scrollElement] + [scrollElement] ); const onChangeLogsSortOrder = () => { @@ -644,7 +637,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { onCloseCallbackRef?.current(); }, [contextRow?.datasourceType, contextRow?.uid, onCloseCallbackRef]); - const onOpenContext = (row: LogRowModel, onClose: () => void) => { + const onOpenContext = useCallback((row: LogRowModel, onClose: () => void) => { // we are setting the `contextOpen` open state and passing it down to the `LogRow` in order to highlight the row when a LogContext is open setContextOpen(true); setContextRow(row); @@ -653,37 +646,40 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { logRowUid: row.uid, }); onCloseCallbackRef.current = onClose; - }; + }, []); - const onPermalinkClick = async (row: LogRowModel) => { - // this is an extra check, to be sure that we are not - // creating permalinks for logs without an id-field. - // normally it should never happen, because we do not - // display the permalink button in such cases. - if (row.rowId === undefined) { - return; - } + const onPermalinkClick = useCallback( + async (row: LogRowModel) => { + // this is an extra check, to be sure that we are not + // creating permalinks for logs without an id-field. + // normally it should never happen, because we do not + // display the permalink button in such cases. + if (row.rowId === undefined) { + return; + } - // get explore state, add log-row-id and make timerange absolute - const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!); - urlState.panelsState = { - ...panelState, - logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields }, - }; - urlState.range = getLogsPermalinkRange(row, logRows, absoluteRange); + // get explore state, add log-row-id and make timerange absolute + const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!); + urlState.panelsState = { + ...panelState, + logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields }, + }; + urlState.range = getLogsPermalinkRange(row, logRows, absoluteRange); - // append changed urlState to baseUrl - const serializedState = serializeStateToUrlParam(urlState); - const baseUrl = /.*(?=\/explore)/.exec(`${window.location.href}`)![0]; - const url = urlUtil.renderUrl(`${baseUrl}/explore`, { left: serializedState }); - await createAndCopyShortLink(url); + // append changed urlState to baseUrl + const serializedState = serializeStateToUrlParam(urlState); + const baseUrl = /.*(?=\/explore)/.exec(`${window.location.href}`)![0]; + const url = urlUtil.renderUrl(`${baseUrl}/explore`, { left: serializedState }); + await createAndCopyShortLink(url); - reportInteraction('grafana_explore_logs_permalink_clicked', { - datasourceType: row.datasourceType ?? 'unknown', - logRowUid: row.uid, - logRowLevel: row.logLevel, - }); - }; + reportInteraction('grafana_explore_logs_permalink_clicked', { + datasourceType: row.datasourceType ?? 'unknown', + logRowUid: row.uid, + logRowLevel: row.logLevel, + }); + }, + [absoluteRange, displayedFields, exploreId, logRows, panelState, visualisationType] + ); const scrollToTopLogs = useCallback(() => { if (config.featureToggles.logsInfiniteScrolling) { @@ -697,55 +693,62 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { topLogsRef.current?.scrollIntoView(); }, [logsContainerRef, topLogsRef]); - const onPinToContentOutlineClick = (row: LogRowModel, allowUnPin = true) => { - if (getPinnedLogsCount() === PINNED_LOGS_LIMIT && !allowUnPin) { - contentOutlineTrackPinLimitReached(); - return; - } + const onPinToContentOutlineClick = useCallback( + (row: LogRowModel, allowUnPin = true) => { + if (getPinnedLogsCount() === PINNED_LOGS_LIMIT && !allowUnPin) { + contentOutlineTrackPinLimitReached(); + return; + } - // find the Logs parent item - const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root'); + // find the Logs parent item + const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root'); - //update the parent's expanded state - if (logsParent && updateItem) { - updateItem(logsParent.id, { expanded: true }); - } + //update the parent's expanded state + if (logsParent && updateItem) { + updateItem(logsParent.id, { expanded: true }); + } - const alreadyPinned = pinnedLogs?.find((pin) => pin === row.rowId); - if (alreadyPinned && row.rowId && allowUnPin) { - unregister?.(row.rowId); - contentOutlineTrackPinRemoved(); - } else if (getPinnedLogsCount() !== PINNED_LOGS_LIMIT && !alreadyPinned) { - register?.({ - id: row.rowId, - icon: 'gf-logs', - title: PINNED_LOGS_TITLE, - panelId: PINNED_LOGS_PANELID, - level: 'child', - ref: null, - color: LogLevelColor[row.logLevel], - childOnTop: true, - onClick: () => { - onOpenContext(row, () => {}); - contentOutlineTrackPinClicked(); - }, - onRemove: (id: string) => { - unregister?.(id); - contentOutlineTrackUnpinClicked(); - }, - }); - contentOutlineTrackPinAdded(); - } + const alreadyPinned = pinnedLogs?.find((pin) => pin === row.rowId); + if (alreadyPinned && row.rowId && allowUnPin) { + unregister?.(row.rowId); + contentOutlineTrackPinRemoved(); + } else if (getPinnedLogsCount() !== PINNED_LOGS_LIMIT && !alreadyPinned) { + register?.({ + id: row.rowId, + icon: 'gf-logs', + title: PINNED_LOGS_TITLE, + panelId: PINNED_LOGS_PANELID, + level: 'child', + ref: null, + color: LogLevelColor[row.logLevel], + childOnTop: true, + onClick: () => { + onOpenContext(row, () => {}); + contentOutlineTrackPinClicked(); + }, + onRemove: (id: string) => { + unregister?.(id); + contentOutlineTrackUnpinClicked(); + }, + }); + contentOutlineTrackPinAdded(); + } - props.onPinLineCallback?.(); - }; + onPinLineCallback?.(); + }, + [getPinnedLogsCount, onOpenContext, onPinLineCallback, outlineItems, pinnedLogs, register, unregister, updateItem] + ); - const hasUnescapedContent = checkUnescapedContent(logRows); - const filteredLogs = filterRows(logRows, hiddenLogLevels); - const { dedupedRows, dedupCount } = dedupRows(filteredLogs, dedupStrategy); - const navigationRange = createNavigationRange(logRows); - const infiniteScrollAvailable = !logsQueries?.some( - (query) => 'direction' in query && query.direction === LokiQueryDirection.Scan + const hasUnescapedContent = useMemo(() => checkUnescapedContent(logRows), [logRows]); + const filteredLogs = useMemo(() => filterRows(logRows, hiddenLogLevels), [hiddenLogLevels, logRows]); + const { dedupedRows, dedupCount } = useMemo( + () => dedupRows(filteredLogs, dedupStrategy), + [dedupStrategy, filteredLogs] + ); + const navigationRange = useMemo(() => createNavigationRange(logRows), [logRows]); + const infiniteScrollAvailable = useMemo( + () => !logsQueries?.some((query) => 'direction' in query && query.direction === LokiQueryDirection.Scan), + [logsQueries] ); return ( @@ -938,58 +941,61 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { />
)} - {visualisationType === 'logs' && hasData && ( + {visualisationType === 'logs' && (
- - - + > + + + )}
)} {!loading && !hasData && !scanning && ( @@ -1098,21 +1104,21 @@ const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean, tableHeight: n }; }; -const checkUnescapedContent = memoizeOne((logRows: LogRowModel[]) => { +const checkUnescapedContent = (logRows: LogRowModel[]) => { return logRows.some((r) => r.hasUnescapedContent); -}); +}; -const dedupRows = memoizeOne((logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) => { +const dedupRows = (logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) => { const dedupedRows = dedupLogRows(logRows, dedupStrategy); const dedupCount = dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0); return { dedupedRows, dedupCount }; -}); +}; -const filterRows = memoizeOne((logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => { +const filterRows = (logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => { return filterLogLevels(logRows, new Set(hiddenLogLevels)); -}); +}; -const createNavigationRange = memoizeOne((logRows: LogRowModel[]): { from: number; to: number } | undefined => { +const createNavigationRange = (logRows: LogRowModel[]): { from: number; to: number } | undefined => { if (!logRows || logRows.length === 0) { return undefined; } @@ -1124,4 +1130,4 @@ const createNavigationRange = memoizeOne((logRows: LogRowModel[]): { from: numbe } return { from: firstTimeStamp, to: lastTimeStamp }; -}); +}; diff --git a/public/app/features/explore/Logs/LogsContainer.tsx b/public/app/features/explore/Logs/LogsContainer.tsx index 0b56ffdf521..0b727f33c9d 100644 --- a/public/app/features/explore/Logs/LogsContainer.tsx +++ b/public/app/features/explore/Logs/LogsContainer.tsx @@ -259,6 +259,14 @@ class LogsContainer extends PureComponent { + this.props.loadSupplementaryQueryData(this.props.exploreId, SupplementaryQueryType.LogsVolume); + }; + + onSetLogsVolumeEnabled = (enabled: boolean) => { + this.props.setSupplementaryQueryEnabled(this.props.exploreId, enabled, SupplementaryQueryType.LogsVolume); + }; + render() { const { loading, @@ -267,8 +275,6 @@ class LogsContainer extends PureComponent - setSupplementaryQueryEnabled(exploreId, enabled, SupplementaryQueryType.LogsVolume) - } + onSetLogsVolumeEnabled={this.onSetLogsVolumeEnabled} logsVolumeData={logsVolume.data} logsQueries={logsQueries} width={width} splitOpen={splitOpenFn} loading={loading} loadingState={loadingState} - loadLogsVolumeData={() => loadSupplementaryQueryData(exploreId, SupplementaryQueryType.LogsVolume)} + loadLogsVolumeData={this.loadLogsVolumeData} onChangeTime={this.onChangeTime} loadMoreLogs={this.loadMoreLogs} onClickFilterLabel={this.logDetailsFilterAvailable() ? onClickFilterLabel : undefined} diff --git a/public/app/features/explore/Logs/LogsSamplePanel.tsx b/public/app/features/explore/Logs/LogsSamplePanel.tsx index 44bc1430d57..63ec5219b06 100644 --- a/public/app/features/explore/Logs/LogsSamplePanel.tsx +++ b/public/app/features/explore/Logs/LogsSamplePanel.tsx @@ -101,6 +101,7 @@ export function LogsSamplePanel(props: Props) { prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)} timeZone={timeZone} enableLogDetails={true} + scrollElement={null} /> diff --git a/public/app/features/logs/components/InfiniteScroll.test.tsx b/public/app/features/logs/components/InfiniteScroll.test.tsx index 666932be9d9..c6b92afaef5 100644 --- a/public/app/features/logs/components/InfiniteScroll.test.tsx +++ b/public/app/features/logs/components/InfiniteScroll.test.tsx @@ -28,6 +28,7 @@ const defaultProps: Omit = { rows: [], sortOrder: LogsSortOrder.Descending, timeZone: 'browser', + scrollElement: null, }; function ScrollWithWrapper({ children, ...props }: Props) { diff --git a/public/app/features/logs/components/InfiniteScroll.tsx b/public/app/features/logs/components/InfiniteScroll.tsx index d8cbeb918f3..4fffcf4c62e 100644 --- a/public/app/features/logs/components/InfiniteScroll.tsx +++ b/public/app/features/logs/components/InfiniteScroll.tsx @@ -17,7 +17,7 @@ export type Props = { loadMoreLogs?: (range: AbsoluteTimeRange) => void; range: TimeRange; rows: LogRowModel[]; - scrollElement?: HTMLDivElement; + scrollElement: HTMLDivElement | null; sortOrder: LogsSortOrder; timeZone: TimeZone; topScrollEnabled?: boolean; diff --git a/public/app/features/logs/components/LogRow.test.tsx b/public/app/features/logs/components/LogRow.test.tsx index bf8a34cd98f..04e8f5ecb6a 100644 --- a/public/app/features/logs/components/LogRow.test.tsx +++ b/public/app/features/logs/components/LogRow.test.tsx @@ -62,7 +62,7 @@ describe('LogRow', () => { describe('with permalinking', () => { it('reports via feature tracking when log line matches', () => { const scrollIntoView = jest.fn(); - setup({ permalinkedRowId: 'log-row-id', scrollIntoView, containerRendered: true }); + setup({ permalinkedRowId: 'log-row-id', scrollIntoView }); expect(reportInteraction).toHaveBeenCalledWith('grafana_explore_logs_permalink_opened', { logRowUid: 'log-row-id', datasourceType: 'unknown', @@ -73,7 +73,6 @@ describe('LogRow', () => { it('highlights row with same permalink-id', () => { const { container } = setup({ permalinkedRowId: 'log-row-id', - containerRendered: true, scrollIntoView: jest.fn(), }); const row = container.querySelector('tr'); @@ -86,7 +85,6 @@ describe('LogRow', () => { const { container } = setup({ permalinkedRowId: 'log-row-id', enableLogDetails: true, - containerRendered: true, scrollIntoView: jest.fn(), }); const row = container.querySelector('tr'); @@ -111,28 +109,22 @@ describe('LogRow', () => { it('calls `scrollIntoView` if permalink matches', () => { const scrollIntoView = jest.fn(); - setup({ permalinkedRowId: 'log-row-id', scrollIntoView, containerRendered: true }); + setup({ permalinkedRowId: 'log-row-id', scrollIntoView }); expect(scrollIntoView).toHaveBeenCalled(); }); it('does not call `scrollIntoView` if permalink does not match', () => { const scrollIntoView = jest.fn(); - setup({ permalinkedRowId: 'wrong-log-row-id', scrollIntoView, containerRendered: true }); + setup({ permalinkedRowId: 'wrong-log-row-id', scrollIntoView }); expect(scrollIntoView).not.toHaveBeenCalled(); }); it('calls `scrollIntoView` once', async () => { const scrollIntoView = jest.fn(); - setup({ permalinkedRowId: 'log-row-id', scrollIntoView, containerRendered: true }); + setup({ permalinkedRowId: 'log-row-id', scrollIntoView }); await userEvent.hover(screen.getByText('test123')); expect(scrollIntoView).toHaveBeenCalledTimes(1); }); - - it('does not call `scrollIntoView` if permalink matches but container is not rendered yet', () => { - const scrollIntoView = jest.fn(); - setup({ permalinkedRowId: 'log-row-id', scrollIntoView, containerRendered: false }); - expect(scrollIntoView).not.toHaveBeenCalled(); - }); }); it('should render the menu cell on mouse over', async () => { diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index 4cb008a314b..49a8f86c32a 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -1,8 +1,5 @@ -import { cx } from '@emotion/css'; import { debounce } from 'lodash'; -import memoizeOne from 'memoize-one'; -import * as React from 'react'; -import { MouseEvent, PureComponent, ReactNode } from 'react'; +import { MouseEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CoreApp, @@ -16,7 +13,7 @@ import { } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; -import { Icon, PopoverContent, Themeable2, Tooltip, withTheme2 } from '@grafana/ui'; +import { Icon, PopoverContent, Tooltip, useTheme2 } from '@grafana/ui'; import { checkLogsError, checkLogsSampled, escapeUnescapedString } from '../utils'; @@ -26,7 +23,7 @@ import { LogRowMessage } from './LogRowMessage'; import { LogRowMessageDisplayedFields } from './LogRowMessageDisplayedFields'; import { getLogLevelStyles, LogRowStyles } from './getLogRowStyles'; -interface Props extends Themeable2 { +export interface Props { row: LogRowModel; showDuplicates: boolean; showLabels: boolean; @@ -63,293 +60,261 @@ interface Props extends Themeable2 { onUnpinLine?: (row: LogRowModel) => void; pinLineButtonTooltipTitle?: PopoverContent; pinned?: boolean; - containerRendered?: boolean; handleTextSelection?: (e: MouseEvent, row: LogRowModel) => boolean; logRowMenuIconsBefore?: ReactNode[]; logRowMenuIconsAfter?: ReactNode[]; } -interface State { - permalinked: boolean; - showingContext: boolean; - showDetails: boolean; - mouseIsOver: boolean; -} +export const LogRow = ({ + getRows, + onClickFilterLabel, + onClickFilterOutLabel, + onClickShowField, + onClickHideField, + enableLogDetails, + row, + showDuplicates, + showContextToggle, + showLabels, + showTime, + displayedFields, + wrapLogMessage, + prettifyLogMessage, + getFieldLinks, + forceEscape, + app, + styles, + getRowContextQuery, + pinned, + logRowMenuIconsBefore, + logRowMenuIconsAfter, + timeZone, + permalinkedRowId, + scrollIntoView, + handleTextSelection, + onLogRowHover, + ...props +}: Props) => { + const [showingContext, setShowingContext] = useState(false); + const [showDetails, setShowDetails] = useState(false); + const [mouseIsOver, setMouseIsOver] = useState(false); + const [permalinked, setPermalinked] = useState(false); + const logLineRef = useRef(null); + const theme = useTheme2(); -/** - * Renders a log line. - * - * When user hovers over it for a certain time, it lazily parses the log line. - * Once a parser is found, it will determine fields, that will be highlighted. - * When the user requests stats for a field, they will be calculated and rendered below the row. - */ -class UnThemedLogRow extends PureComponent { - state: State = { - permalinked: false, - showingContext: false, - showDetails: false, - mouseIsOver: false, - }; - logLineRef: React.RefObject; - - constructor(props: Props) { - super(props); - this.logLineRef = React.createRef(); - } - - // we are debouncing the state change by 3 seconds to highlight the logline after the context closed. - debouncedContextClose = debounce(() => { - this.setState({ showingContext: false }); - }, 3000); - - onOpenContext = (row: LogRowModel) => { - this.setState({ showingContext: true }); - this.props.onOpenContext(row, this.debouncedContextClose); - }; - - onRowClick = (e: MouseEvent) => { - if (this.props.handleTextSelection?.(e, this.props.row)) { - // Event handled by the parent. - return; - } - - if (!this.props.enableLogDetails) { - return; - } - - this.setState((state) => { - return { - showDetails: !state.showDetails, - }; - }); - }; - - renderTimeStamp(epochMs: number) { - return dateTimeFormat(epochMs, { - timeZone: this.props.timeZone, - defaultWithMS: true, - }); - } - - onMouseEnter = () => { - this.setState({ mouseIsOver: true }); - if (this.props.onLogRowHover) { - this.props.onLogRowHover(this.props.row); - } - }; - - onMouseMove = (e: MouseEvent) => { - // No need to worry about text selection. - if (!this.props.handleTextSelection) { - return; - } - // The user is selecting text, so hide the log row menu so it doesn't interfere. - if (document.getSelection()?.toString() && e.buttons > 0) { - this.setState({ mouseIsOver: false }); - } - }; - - onMouseLeave = () => { - this.setState({ mouseIsOver: false }); - }; - - componentDidMount() { - this.scrollToLogRow(this.state, true); - } - - componentDidUpdate(_: Props, prevState: State) { - this.scrollToLogRow(prevState); - } - - scrollToLogRow = (prevState: State, mounted = false) => { - const { row, permalinkedRowId, scrollIntoView, containerRendered } = this.props; + const timestamp = useMemo( + () => + dateTimeFormat(row.timeEpochMs, { + timeZone: timeZone, + defaultWithMS: true, + }), + [row.timeEpochMs, timeZone] + ); + const levelStyles = useMemo(() => getLogLevelStyles(theme, row.logLevel), [row.logLevel, theme]); + const processedRow = useMemo( + () => + row.hasUnescapedContent && forceEscape + ? { ...row, entry: escapeUnescapedString(row.entry), raw: escapeUnescapedString(row.raw) } + : row, + [forceEscape, row] + ); + const errorMessage = checkLogsError(row); + const hasError = errorMessage !== undefined; + const sampleMessage = checkLogsSampled(row); + const isSampled = sampleMessage !== undefined; + useEffect(() => { if (permalinkedRowId !== row.uid) { - // only set the new state if the row is not permalinked anymore or if the component was mounted. - if (prevState.permalinked || mounted) { - this.setState({ permalinked: false }); - } + setPermalinked(false); + return; + } + if (!permalinked) { + setPermalinked(true); return; } - if (!this.state.permalinked && containerRendered && this.logLineRef.current && scrollIntoView) { + if (logLineRef.current && scrollIntoView) { // at this point this row is the permalinked row, so we need to scroll to it and highlight it if possible. - scrollIntoView(this.logLineRef.current); + scrollIntoView(logLineRef.current); reportInteraction('grafana_explore_logs_permalink_opened', { datasourceType: row.datasourceType ?? 'unknown', logRowUid: row.uid, }); - this.setState({ permalinked: true }); + setPermalinked(true); } - }; + }, [permalinked, permalinkedRowId, row.datasourceType, row.uid, scrollIntoView]); - escapeRow = memoizeOne((row: LogRowModel, forceEscape: boolean | undefined) => { - return row.hasUnescapedContent && forceEscape - ? { ...row, entry: escapeUnescapedString(row.entry), raw: escapeUnescapedString(row.raw) } - : row; - }); + // we are debouncing the state change by 3 seconds to highlight the logline after the context closed. + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedContextClose = useCallback( + debounce(() => { + setShowingContext(false); + }, 3000), + [] + ); - render() { - const { - getRows, - onClickFilterLabel, - onClickFilterOutLabel, - onClickShowField, - onClickHideField, - enableLogDetails, - row, - showDuplicates, - showContextToggle, - showLabels, - showTime, - displayedFields, - wrapLogMessage, - prettifyLogMessage, - theme, - getFieldLinks, - forceEscape, - app, - styles, - getRowContextQuery, - pinned, - logRowMenuIconsBefore, - logRowMenuIconsAfter, - } = this.props; + const onOpenContext = useCallback( + (row: LogRowModel) => { + setShowingContext(true); + props.onOpenContext(row, debouncedContextClose); + }, + [debouncedContextClose, props] + ); - const { showDetails, showingContext, permalinked } = this.state; - const levelStyles = getLogLevelStyles(theme, row.logLevel); - const { errorMessage, hasError } = checkLogsError(row); - const { sampleMessage, isSampled } = checkLogsSampled(row); - const logRowBackground = cx(styles.logsRow, { - [styles.errorLogRow]: hasError, - [styles.highlightBackground]: showingContext || permalinked || pinned, - }); - const logRowDetailsBackground = cx(styles.logsRow, { - [styles.errorLogRow]: hasError, - [styles.highlightBackground]: permalinked && !this.state.showDetails, - }); + const onRowClick = useCallback( + (e: MouseEvent) => { + if (handleTextSelection?.(e, row)) { + // Event handled by the parent. + return; + } - const processedRow = this.escapeRow(row, forceEscape); + if (!enableLogDetails) { + return; + } - return ( - <> - !showDetails); + }, + [enableLogDetails, handleTextSelection, row] + ); + + const onMouseEnter = useCallback(() => { + setMouseIsOver(true); + if (onLogRowHover) { + onLogRowHover(row); + } + }, [onLogRowHover, row]); + + const onMouseMove = useCallback( + (e: MouseEvent) => { + // No need to worry about text selection. + if (!handleTextSelection) { + return; + } + // The user is selecting text, so hide the log row menu so it doesn't interfere. + if (document.getSelection()?.toString() && e.buttons > 0) { + setMouseIsOver(false); + } + }, + [handleTextSelection] + ); + + const onMouseLeave = useCallback(() => { + setMouseIsOver(false); + }, []); + + return ( + <> + + {showDuplicates && ( + + {processedRow.duplicates && processedRow.duplicates > 0 ? `${processedRow.duplicates + 1}x` : null} + + )} + - {showDuplicates && ( - - {processedRow.duplicates && processedRow.duplicates > 0 ? `${processedRow.duplicates + 1}x` : null} - + {hasError && ( + + + )} - - {hasError && ( - - - - )} - {isSampled && ( - - - - )} + {isSampled && ( + + + + )} + + + {enableLogDetails && ( + + )} + + {showTime && {timestamp}} + {showLabels && processedRow.uniqueLabels && ( + + - - {enableLogDetails && ( - - )} - - {showTime && {this.renderTimeStamp(row.timeEpochMs)}} - {showLabels && processedRow.uniqueLabels && ( - - - - )} - {displayedFields && displayedFields.length > 0 ? ( - - ) : ( - - )} - - {this.state.showDetails && ( - 0 ? ( + + ) : ( + )} - - ); - } -} - -export const LogRow = withTheme2(UnThemedLogRow); -LogRow.displayName = 'LogRow'; + + {showDetails && ( + + )} + + ); +}; diff --git a/public/app/features/logs/components/LogRowMessage.tsx b/public/app/features/logs/components/LogRowMessage.tsx index 77f240caaa1..f771d9a2bb6 100644 --- a/public/app/features/logs/components/LogRowMessage.tsx +++ b/public/app/features/logs/components/LogRowMessage.tsx @@ -156,7 +156,7 @@ export const LogRowMessage = memo((props: Props) => { () => restructureLog(raw, prettifyLogMessage, wrapLogMessage, Boolean(expanded)), [raw, prettifyLogMessage, wrapLogMessage, expanded] ); - const shouldShowMenu = useMemo(() => mouseIsOver || pinned, [mouseIsOver, pinned]); + const shouldShowMenu = mouseIsOver || pinned; return ( <> { diff --git a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx index 2cc495148a1..3378f8891d7 100644 --- a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx +++ b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx @@ -24,6 +24,7 @@ export interface Props { onBlur: () => void; logRowMenuIconsBefore?: ReactNode[]; logRowMenuIconsAfter?: ReactNode[]; + preview?: boolean; } export const LogRowMessageDisplayedFields = memo((props: Props) => { @@ -37,6 +38,7 @@ export const LogRowMessageDisplayedFields = memo((props: Props) => { pinned, logRowMenuIconsBefore, logRowMenuIconsAfter, + preview, ...rest } = props; const wrapClassName = wrapLogMessage ? '' : displayedFieldsStyles.noWrap; @@ -52,8 +54,7 @@ export const LogRowMessageDisplayedFields = memo((props: Props) => { } const field = fields.find((field) => { - const { keys } = field; - return keys[0] === parsedKey; + return field.keys[0] === parsedKey; }); if (field != null) { @@ -67,7 +68,18 @@ export const LogRowMessageDisplayedFields = memo((props: Props) => { return line.trimStart(); }, [detectedFields, fields, row.entry, row.labels]); - const shouldShowMenu = useMemo(() => mouseIsOver || pinned, [mouseIsOver, pinned]); + const shouldShowMenu = mouseIsOver || pinned; + + if (preview) { + return ( + <> + +
{line}
+ + + + ); + } return ( <> diff --git a/public/app/features/logs/components/LogRows.test.tsx b/public/app/features/logs/components/LogRows.test.tsx index cd861e2e37d..d892327b836 100644 --- a/public/app/features/logs/components/LogRows.test.tsx +++ b/public/app/features/logs/components/LogRows.test.tsx @@ -1,10 +1,9 @@ -import { act, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { range } from 'lodash'; import { LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; -import { LogRows, PREVIEW_LIMIT, Props } from './LogRows'; +import { LogRows, Props } from './LogRows'; import { createLogRow } from './__mocks__/logRow'; jest.mock('@grafana/runtime', () => ({ @@ -34,6 +33,7 @@ describe('LogRows', () => { onClickFilterOutLabel={() => {}} onClickHideField={() => {}} onClickShowField={() => {}} + scrollElement={null} /> ); @@ -43,57 +43,6 @@ describe('LogRows', () => { expect(screen.queryAllByRole('row').at(2)).toHaveTextContent('log message 3'); }); - it('renders rows only limited number of rows first', () => { - const rows: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' }), createLogRow({ uid: '3' })]; - jest.useFakeTimers(); - const { rerender } = render( - - ); - - // There is an extra row with the rows that are rendering - expect(screen.queryAllByRole('row')).toHaveLength(2); - expect(screen.queryAllByRole('row').at(0)).toHaveTextContent('log message 1'); - - act(() => { - jest.runAllTimers(); - }); - rerender( - {}} - onClickFilterOutLabel={() => {}} - onClickHideField={() => {}} - onClickShowField={() => {}} - /> - ); - - expect(screen.queryAllByRole('row')).toHaveLength(3); - expect(screen.queryAllByRole('row').at(0)).toHaveTextContent('log message 1'); - expect(screen.queryAllByRole('row').at(1)).toHaveTextContent('log message 2'); - expect(screen.queryAllByRole('row').at(2)).toHaveTextContent('log message 3'); - - jest.useRealTimers(); - }); - it('renders deduped rows if supplied', () => { const rows: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' }), createLogRow({ uid: '3' })]; const dedupedRows: LogRowModel[] = [createLogRow({ uid: '4' }), createLogRow({ uid: '5' })]; @@ -113,6 +62,7 @@ describe('LogRows', () => { onClickFilterOutLabel={() => {}} onClickHideField={() => {}} onClickShowField={() => {}} + scrollElement={null} /> ); expect(screen.queryAllByRole('row')).toHaveLength(2); @@ -120,31 +70,6 @@ describe('LogRows', () => { expect(screen.queryAllByRole('row').at(1)).toHaveTextContent('log message 5'); }); - it('renders with default preview limit', () => { - // PREVIEW_LIMIT * 2 is there because otherwise we just render all rows - const rows: LogRowModel[] = range(PREVIEW_LIMIT * 2 + 1).map((num) => createLogRow({ uid: num.toString() })); - render( - {}} - onClickFilterOutLabel={() => {}} - onClickHideField={() => {}} - onClickShowField={() => {}} - /> - ); - - // There is an extra row with the rows that are rendering - expect(screen.queryAllByRole('row')).toHaveLength(101); - }); - it('renders asc ordered rows if order and function supplied', () => { const rows: LogRowModel[] = [ createLogRow({ uid: '1', timeEpochMs: 1 }), @@ -167,6 +92,7 @@ describe('LogRows', () => { onClickFilterOutLabel={() => {}} onClickHideField={() => {}} onClickShowField={() => {}} + scrollElement={null} /> ); @@ -196,6 +122,7 @@ describe('LogRows', () => { onClickFilterOutLabel={() => {}} onClickHideField={() => {}} onClickShowField={() => {}} + scrollElement={null} /> ); @@ -222,6 +149,7 @@ describe('Popover menu', () => { displayedFields={[]} onClickFilterOutString={() => {}} onClickFilterString={() => {}} + scrollElement={null} {...overrides} /> ); diff --git a/public/app/features/logs/components/LogRows.tsx b/public/app/features/logs/components/LogRows.tsx index 94b35c7d81d..13863170c75 100644 --- a/public/app/features/logs/components/LogRows.tsx +++ b/public/app/features/logs/components/LogRows.tsx @@ -1,6 +1,5 @@ import { cx } from '@emotion/css'; -import memoizeOne from 'memoize-one'; -import { PureComponent, MouseEvent, createRef, ReactNode } from 'react'; +import { MouseEvent, ReactNode, useState, useMemo, useCallback, useRef, useEffect, memo } from 'react'; import { TimeZone, @@ -15,7 +14,7 @@ import { } from '@grafana/data'; import { config } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; -import { withTheme2, Themeable2, PopoverContent } from '@grafana/ui'; +import { PopoverContent, useTheme2 } from '@grafana/ui'; import { PopoverMenu } from '../../explore/Logs/PopoverMenu'; import { UniqueKeyMaker } from '../UniqueKeyMaker'; @@ -23,11 +22,10 @@ import { sortLogRows, targetIsElement } from '../utils'; //Components import { LogRow } from './LogRow'; +import { PreviewLogRow } from './PreviewLogRow'; import { getLogRowStyles } from './getLogRowStyles'; -export const PREVIEW_LIMIT = 100; - -export interface Props extends Themeable2 { +export interface Props { logRows?: LogRowModel[]; deduplicatedRows?: LogRowModel[]; dedupStrategy: LogsDedupStrategy; @@ -64,7 +62,6 @@ export interface Props extends Themeable2 { isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise; pinnedRowId?: string; pinnedLogs?: string[]; - containerRendered?: boolean; /** * If false or undefined, the `contain:strict` css property will be added to the wrapping `` for performance reasons. * Any overflowing content will be clipped at the table boundary. @@ -74,219 +71,223 @@ export interface Props extends Themeable2 { onClickFilterOutString?: (value: string, refId?: string) => void; logRowMenuIconsBefore?: ReactNode[]; logRowMenuIconsAfter?: ReactNode[]; + scrollElement: HTMLDivElement | null; + renderPreview?: boolean; } -interface State { - renderAll: boolean; +type PopoverStateType = { selection: string; selectedRow: LogRowModel | null; popoverMenuCoordinates: { x: number; y: number }; -} +}; -class UnThemedLogRows extends PureComponent { - renderAllTimer: number | null = null; - logRowsRef = createRef(); - - static defaultProps = { - previewLimit: PREVIEW_LIMIT, - }; - - state: State = { - renderAll: false, - selection: '', - selectedRow: null, - popoverMenuCoordinates: { x: 0, y: 0 }, - }; - - /** - * Toggle the `contextIsOpen` state when a context of one LogRow is opened in order to not show the menu of the other log rows. - */ - openContext = (row: LogRowModel, onClose: () => void): void => { - if (this.props.onOpenContext) { - this.props.onOpenContext(row, onClose); - } - }; - - popoverMenuSupported() { - if (!config.featureToggles.logRowsPopoverMenu) { - return false; - } - return Boolean(this.props.onClickFilterOutString || this.props.onClickFilterString); - } - - handleSelection = (e: MouseEvent, row: LogRowModel): boolean => { - const selection = document.getSelection()?.toString(); - if (!selection) { - return false; - } - if (this.popoverMenuSupported() === false) { - // This signals onRowClick inside LogRow to skip the event because the user is selecting text - return selection ? true : false; - } - - if (!this.logRowsRef.current) { - return false; - } - - const MENU_WIDTH = 270; - const MENU_HEIGHT = 105; - const x = e.clientX + MENU_WIDTH > window.innerWidth ? window.innerWidth - MENU_WIDTH : e.clientX; - const y = e.clientY + MENU_HEIGHT > window.innerHeight ? window.innerHeight - MENU_HEIGHT : e.clientY; - - this.setState({ - selection, - popoverMenuCoordinates: { x, y }, - selectedRow: row, - }); - document.addEventListener('click', this.handleDeselection); - document.addEventListener('contextmenu', this.handleDeselection); - return true; - }; - - handleDeselection = (e: Event) => { - if (targetIsElement(e.target) && !this.logRowsRef.current?.contains(e.target)) { - // The mouseup event comes from outside the log rows, close the menu. - this.closePopoverMenu(); - return; - } - if (document.getSelection()?.toString()) { - return; - } - this.closePopoverMenu(); - }; - - closePopoverMenu = () => { - document.removeEventListener('click', this.handleDeselection); - document.removeEventListener('contextmenu', this.handleDeselection); - this.setState({ +export const LogRows = memo( + ({ + deduplicatedRows, + logRows = [], + dedupStrategy, + logsSortOrder, + previewLimit, + pinnedLogs, + onOpenContext, + onClickFilterOutString, + onClickFilterString, + scrollElement, + renderPreview = false, + enableLogDetails, + permalinkedRowId, + ...props + }: Props) => { + const [previewSize, setPreviewSize] = useState( + /** + * If renderPreview is enabled, either half of the log rows or twice the screen size of log rows will be rendered. + * The biggest of those values will be used. Else, all rows are rendered. + */ + renderPreview && !permalinkedRowId + ? Math.max(2 * Math.ceil(window.innerHeight / 20), Math.ceil(logRows.length / 3)) + : Infinity + ); + const [popoverState, setPopoverState] = useState({ selection: '', - popoverMenuCoordinates: { x: 0, y: 0 }, selectedRow: null, + popoverMenuCoordinates: { x: 0, y: 0 }, }); - }; - - componentDidMount() { - // Staged rendering - const { logRows, previewLimit } = this.props; - const rowCount = logRows ? logRows.length : 0; - // Render all right away if not too far over the limit - const renderAll = rowCount <= previewLimit! * 2; - if (renderAll) { - this.setState({ renderAll }); - } else { - this.renderAllTimer = window.setTimeout(() => this.setState({ renderAll: true }), 2000); - } - } - - componentWillUnmount() { - document.removeEventListener('click', this.handleDeselection); - document.removeEventListener('contextmenu', this.handleDeselection); - document.removeEventListener('selectionchange', this.handleDeselection); - if (this.renderAllTimer) { - clearTimeout(this.renderAllTimer); - } - } - - makeGetRows = memoizeOne((orderedRows: LogRowModel[]) => { - return () => orderedRows; - }); - - sortLogs = memoizeOne((logRows: LogRowModel[], logsSortOrder: LogsSortOrder): LogRowModel[] => - sortLogRows(logRows, logsSortOrder) - ); - - render() { - const { deduplicatedRows, logRows, dedupStrategy, theme, logsSortOrder, previewLimit, pinnedLogs, ...rest } = - this.props; - const { renderAll } = this.state; + const logRowsRef = useRef(null); + const theme = useTheme2(); const styles = getLogRowStyles(theme); const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows; - const hasData = logRows && logRows.length > 0; - const dedupCount = dedupedRows - ? dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0) - : 0; + const dedupCount = useMemo( + () => dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0), + [dedupedRows] + ); const showDuplicates = dedupStrategy !== LogsDedupStrategy.none && dedupCount > 0; // Staged rendering - const processedRows = dedupedRows ? dedupedRows : []; - const orderedRows = logsSortOrder ? this.sortLogs(processedRows, logsSortOrder) : processedRows; - const firstRows = orderedRows.slice(0, previewLimit!); - const lastRows = orderedRows.slice(previewLimit!, orderedRows.length); - + const orderedRows = useMemo( + () => (logsSortOrder ? sortLogRows(dedupedRows, logsSortOrder) : dedupedRows), + [dedupedRows, logsSortOrder] + ); // React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead - const getRows = this.makeGetRows(orderedRows); - + const getRows = useMemo(() => () => orderedRows, [orderedRows]); + const handleDeselectionRef = useRef<((e: Event) => void) | null>(null); const keyMaker = new UniqueKeyMaker(); + // eslint-disable-next-line react-hooks/exhaustive-deps + + useEffect(() => { + return () => { + if (handleDeselectionRef.current) { + document.removeEventListener('click', handleDeselectionRef.current); + document.removeEventListener('contextmenu', handleDeselectionRef.current); + } + }; + }, []); + + useEffect(() => { + if (!scrollElement) { + return; + } + + function renderAll() { + setPreviewSize(Infinity); + scrollElement?.removeEventListener('scroll', renderAll); + scrollElement?.removeEventListener('wheel', renderAll); + } + + scrollElement.addEventListener('scroll', renderAll); + scrollElement.addEventListener('wheel', renderAll); + }, [logRows.length, scrollElement]); + + /** + * Toggle the `contextIsOpen` state when a context of one LogRow is opened in order to not show the menu of the other log rows. + */ + const openContext = useCallback( + (row: LogRowModel, onClose: () => void): void => { + if (onOpenContext) { + onOpenContext(row, onClose); + } + }, + [onOpenContext] + ); + + const popoverMenuSupported = useCallback(() => { + if (!config.featureToggles.logRowsPopoverMenu) { + return false; + } + return Boolean(onClickFilterOutString || onClickFilterString); + }, [onClickFilterOutString, onClickFilterString]); + + const closePopoverMenu = useCallback(() => { + if (handleDeselectionRef.current) { + document.removeEventListener('click', handleDeselectionRef.current); + document.removeEventListener('contextmenu', handleDeselectionRef.current); + handleDeselectionRef.current = null; + } + setPopoverState({ + selection: '', + popoverMenuCoordinates: { x: 0, y: 0 }, + selectedRow: null, + }); + }, []); + + const handleDeselection = useCallback( + (e: Event) => { + if (targetIsElement(e.target) && !logRowsRef.current?.contains(e.target)) { + // The mouseup event comes from outside the log rows, close the menu. + closePopoverMenu(); + return; + } + if (document.getSelection()?.toString()) { + return; + } + closePopoverMenu(); + }, + [closePopoverMenu] + ); + + const handleSelection = useCallback( + (e: MouseEvent, row: LogRowModel): boolean => { + const selection = document.getSelection()?.toString(); + if (!selection) { + return false; + } + if (popoverMenuSupported() === false) { + // This signals onRowClick inside LogRow to skip the event because the user is selecting text + return selection ? true : false; + } + + if (!logRowsRef.current) { + return false; + } + + const MENU_WIDTH = 270; + const MENU_HEIGHT = 105; + const x = e.clientX + MENU_WIDTH > window.innerWidth ? window.innerWidth - MENU_WIDTH : e.clientX; + const y = e.clientY + MENU_HEIGHT > window.innerHeight ? window.innerHeight - MENU_HEIGHT : e.clientY; + + setPopoverState({ + selection, + popoverMenuCoordinates: { x, y }, + selectedRow: row, + }); + handleDeselectionRef.current = handleDeselection; + document.addEventListener('click', handleDeselection); + document.addEventListener('contextmenu', handleDeselection); + return true; + }, + [handleDeselection, popoverMenuSupported] + ); return ( -
- {this.state.selection && this.state.selectedRow && ( +
+ {popoverState.selection && popoverState.selectedRow && ( )} -
+
- {hasData && - firstRows.map((row) => ( + {orderedRows.map((row, index) => + index < previewSize ? ( logId === row.rowId)} - isFilterLabelActive={this.props.isFilterLabelActive} - handleTextSelection={this.handleSelection} - {...rest} + onPermalinkClick={props.onPermalinkClick} + scrollIntoView={props.scrollIntoView} + permalinkedRowId={permalinkedRowId} + onPinLine={props.onPinLine} + onUnpinLine={props.onUnpinLine} + pinLineButtonTooltipTitle={props.pinLineButtonTooltipTitle} + pinned={props.pinnedRowId === row.uid || pinnedLogs?.some((logId) => logId === row.rowId)} + isFilterLabelActive={props.isFilterLabelActive} + handleTextSelection={handleSelection} + enableLogDetails={enableLogDetails} + {...props} /> - ))} - {hasData && - renderAll && - lastRows.map((row) => ( - logId === row.rowId)} - isFilterLabelActive={this.props.isFilterLabelActive} - handleTextSelection={this.handleSelection} - {...rest} + showDuplicates={showDuplicates} + {...props} + row={row} /> - ))} - {hasData && !renderAll && ( - - - + ) )}
Rendering {orderedRows.length - previewLimit!} rows...
); } -} - -export const LogRows = withTheme2(UnThemedLogRows); -LogRows.displayName = 'LogsRows'; +); diff --git a/public/app/features/logs/components/PreviewLogRow.tsx b/public/app/features/logs/components/PreviewLogRow.tsx new file mode 100644 index 00000000000..6e2370bee39 --- /dev/null +++ b/public/app/features/logs/components/PreviewLogRow.tsx @@ -0,0 +1,29 @@ +import { Props } from './LogRow'; +import { LogRowMessageDisplayedFields } from './LogRowMessageDisplayedFields'; + +const emptyFn = () => {}; +export const PreviewLogRow = ({ row, showDuplicates, showLabels, showTime, displayedFields, ...rest }: Props) => { + return ( + + {showDuplicates && } + + + {showTime && {row.timeEpochMs}} + {showLabels && row.uniqueLabels && } + {displayedFields ? ( + + ) : ( + {row.entry} + )} + + + ); +}; diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx index d5f89e6d977..29cf6c6c5e3 100644 --- a/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx +++ b/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx @@ -55,31 +55,7 @@ const dfAfter = createDataFrame({ ], }); -let uniqueRefIdCounter = 1; - -const getRowContext = jest.fn().mockImplementation(async (_, options) => { - uniqueRefIdCounter += 1; - const refId = `refid_${uniqueRefIdCounter}`; - if (options.direction === LogRowContextQueryDirection.Forward) { - return { - data: [ - { - refId, - ...dfBefore, - }, - ], - }; - } else { - return { - data: [ - { - refId, - ...dfAfter, - }, - ], - }; - } -}); +let getRowContext = jest.fn(); const dispatchMock = jest.fn(); jest.mock('app/types', () => ({ ...jest.requireActual('app/types'), @@ -102,9 +78,34 @@ const timeZone = 'UTC'; describe('LogRowContextModal', () => { const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView; + let uniqueRefIdCounter = 1; beforeEach(() => { window.HTMLElement.prototype.scrollIntoView = jest.fn(); + uniqueRefIdCounter = 1; + getRowContext = jest.fn().mockImplementation(async (_, options) => { + uniqueRefIdCounter += 1; + const refId = `refid_${uniqueRefIdCounter}`; + if (options.direction === LogRowContextQueryDirection.Forward) { + return { + data: [ + { + refId, + ...dfBefore, + }, + ], + }; + } else { + return { + data: [ + { + refId, + ...dfAfter, + }, + ], + }; + } + }); }); afterEach(() => { window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView; diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.tsx index b1b3dcd36d9..798e03ce065 100644 --- a/public/app/features/logs/components/log-context/LogRowContextModal.tsx +++ b/public/app/features/logs/components/log-context/LogRowContextModal.tsx @@ -540,6 +540,7 @@ export const LogRowContextModal: React.FunctionComponent @@ -562,6 +563,7 @@ export const LogRowContextModal: React.FunctionComponent setSticky(true)} pinnedRowId={sticky ? row.uid : undefined} overflowingContent={true} + scrollElement={null} /> @@ -580,6 +582,7 @@ export const LogRowContextModal: React.FunctionComponent diff --git a/public/app/features/logs/utils.test.ts b/public/app/features/logs/utils.test.ts index 484d16efe7a..289dfd023f8 100644 --- a/public/app/features/logs/utils.test.ts +++ b/public/app/features/logs/utils.test.ts @@ -24,6 +24,7 @@ import { logRowsToReadableJson, mergeLogsVolumeDataFrames, sortLogsResult, + checkLogsSampled, } from './utils'; describe('getLoglevel()', () => { @@ -240,8 +241,36 @@ describe('checkLogsError()', () => { foo: 'boo', } as Labels, } as LogRowModel; - test('should return correct error if error is present', () => { - expect(checkLogsError(log)).toStrictEqual({ hasError: true, errorMessage: 'Error Message' }); + test('should return the error if present', () => { + expect(checkLogsError(log)).toStrictEqual('Error Message'); + }); + test('should return undefined otherwise', () => { + expect(checkLogsError({ ...log, labels: {} })).toStrictEqual(undefined); + }); +}); + +describe('checkLogsSampled()', () => { + const log = { + labels: { + __adaptive_logs_sampled__: 'true', + foo: 'boo', + } as Labels, + } as LogRowModel; + test('should return a message if is sampled', () => { + expect(checkLogsSampled(log)).toStrictEqual('Logs like this one have been dropped by Adaptive Logs'); + }); + test('should return an interpolated message if is sampled', () => { + expect( + checkLogsSampled({ + ...log, + labels: { + __adaptive_logs_sampled__: '10', + }, + }) + ).toStrictEqual('10% of logs like this one have been dropped by Adaptive Logs'); + }); + test('should return undefined otherwise', () => { + expect(checkLogsSampled({ ...log, labels: {} })).toStrictEqual(undefined); }); }); diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index e6db5c67038..4d0ae113c70 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -142,32 +142,17 @@ export const sortLogRows = (logRows: LogRowModel[], sortOrder: LogsSortOrder) => sortOrder === LogsSortOrder.Ascending ? logRows.sort(sortInAscendingOrder) : logRows.sort(sortInDescendingOrder); // Currently supports only error condition in Loki logs -export const checkLogsError = (logRow: LogRowModel): { hasError: boolean; errorMessage?: string } => { - if (logRow.labels.__error__) { - return { - hasError: true, - errorMessage: logRow.labels.__error__, - }; - } - return { - hasError: false, - }; +export const checkLogsError = (logRow: LogRowModel): string | undefined => { + return logRow.labels.__error__; }; -export const checkLogsSampled = (logRow: LogRowModel): { isSampled: boolean; sampleMessage?: string } => { - if (logRow.labels.__adaptive_logs_sampled__) { - let msg = - logRow.labels.__adaptive_logs_sampled__ === 'true' - ? 'Logs like this one have been dropped by Adaptive Logs' - : `${logRow.labels.__adaptive_logs_sampled__}% of logs like this one have been dropped by Adaptive Logs`; - return { - isSampled: true, - sampleMessage: msg, - }; +export const checkLogsSampled = (logRow: LogRowModel): string | undefined => { + if (!logRow.labels.__adaptive_logs_sampled__) { + return undefined; } - return { - isSampled: false, - }; + return logRow.labels.__adaptive_logs_sampled__ === 'true' + ? 'Logs like this one have been dropped by Adaptive Logs' + : `${logRow.labels.__adaptive_logs_sampled__}% of logs like this one have been dropped by Adaptive Logs`; }; export const escapeUnescapedString = (string: string) => diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 135f3eb7a79..9125f82a355 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -425,11 +425,11 @@ export const LogsPanel = ({ range={data.timeRange} timeZone={timeZone} rows={logRows} - scrollElement={scrollElement ?? undefined} + scrollElement={scrollElement} sortOrder={sortOrder} > {showCommonLabels && isAscending && renderCommonLabels()} From 62b326a08c0e22a47a73df6f40c8f9505d2e2da3 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:31:31 -0500 Subject: [PATCH 10/32] Docs: Share panel image preview (#98039) --- .../share-dashboards-panels/_index.md | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/sources/dashboards/share-dashboards-panels/_index.md b/docs/sources/dashboards/share-dashboards-panels/_index.md index ef8e67c1bb0..b9d84b58ab6 100644 --- a/docs/sources/dashboards/share-dashboards-panels/_index.md +++ b/docs/sources/dashboards/share-dashboards-panels/_index.md @@ -52,6 +52,21 @@ refs: destination: /docs/grafana//setup-grafana/image-rendering/#configuration - pattern: /docs/grafana-cloud/ destination: /docs/grafana//setup-grafana/image-rendering/#configuration + max-width: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/image-rendering/#viewport-maximum-width + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/image-rendering/#viewport-maximum-width + max-height: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/image-rendering/#viewport-maximum-height + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/image-rendering/#viewport-maximum-height + max-scale: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/image-rendering/#maximum-device-scale-factor + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/image-rendering/#maximum-device-scale-factor --- # Share dashboards and panels @@ -239,20 +254,29 @@ To share a personalized, direct link to your panel within your organization, fol - **Lock time range** - Change the current relative time range to an absolute time range. This option is enabled by default. - **Shorten link** - Shorten the panel link. This option is disabled by default. 1. Select the theme for the dashboard. Choose from **Current**, **Dark**, or **Light**. -1. Do one or both of the following: - - Click **Copy link**. - - Click **Render image**, which [renders the panel as a PNG image](ref:image-rendering). -1. Send the copied link or image URL to a Grafana user with authorization to view it. +1. Click **Copy link**. +1. Send the copied link to a Grafana user with authorization to view it. +1. (Optional) To [generate an image of the panel as a PNG file](ref:image-rendering), customize the image settings: + + - **Width** - In pixels. The default is 1000. + - **Height** - In pixels. The default is 500. + - **Scale factor** - The default is 1. + + There are maximums for [width](ref:max-width), [height](ref:max-height), and [scale factor](ref:max-scale) in the image renderer configuration that you can customize if needed. + +1. (Optional) Click **Generate image** to see a preview of the panel image. +1. (Optional) Click **Download image**. +1. Send the copied image to a Grafana user with authorization to view it. 1. Click the **X** at the top-right corner to close the share drawer. #### Query string parameters for server-side rendered images -When you click **Render image** in the panel link settings, Grafana generates a PNG image of the panel with the following default parameters: +When you click **Generate image** in the panel link settings, Grafana generates a PNG image of the panel with the following default parameters: | Parameter | Description | | --------- | ------------------------------------------------------------------------------------------------------------------------------ | -| width | Width in pixels. Default is 800. | -| height | Height in pixels. Default is 400. | +| width | Width in pixels. Default is 1000. | +| height | Height in pixels. Default is 500. | | tz | Timezone in the format `UTC%2BHH%3AMM` where HH and MM are offset in hours and minutes after UTC. | | timeout | Number of seconds. The timeout can be increased if the query for the panel needs more than the default 30 seconds. | | scale | Numeric value to configure device scale factor. Default is 1. Use a higher value to produce more detailed images (higher DPI). | From 2c3c06e00fc50f631176e5d9165b4ce834d4a960 Mon Sep 17 00:00:00 2001 From: Juan Cabanas Date: Wed, 18 Dec 2024 16:32:41 -0300 Subject: [PATCH 11/32] SharePanelInternally: Add render image inside drawer with customization (#97179) --- .../src/selectors/pages.ts | 20 ++ .../scene/PanelMenuBehavior.tsx | 17 +- .../dashboard-scene/sharing/ShareLinkTab.tsx | 11 +- .../panel-share/SharePanelInternally.test.tsx | 78 ++++++ .../panel-share/SharePanelInternally.tsx | 92 ++++--- .../sharing/panel-share/SharePanelPreview.tsx | 228 ++++++++++++++++++ .../dashboard-scene/utils/interactions.ts | 6 + .../dashboard-scene/utils/urlBuilders.ts | 4 +- .../components/ShareModal/ShareEmbed.tsx | 7 +- public/locales/en-US/grafana.json | 26 +- public/locales/pseudo-LOCALE/grafana.json | 26 +- 11 files changed, 450 insertions(+), 65 deletions(-) create mode 100644 public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx create mode 100644 public/app/features/dashboard-scene/sharing/panel-share/SharePanelPreview.tsx diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 45ab6518453..ae0a8e08d3f 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -697,6 +697,26 @@ export const versionedPages = { copyUrlButton: { '11.3.0': 'data-testid share internally copy url button', }, + SharePanel: { + preview: { + '11.5.0': 'data-testid share panel internally image generation preview', + }, + widthInput: { + '11.5.0': 'data-testid share panel internally width input', + }, + heightInput: { + '11.5.0': 'data-testid share panel internally height input', + }, + scaleFactorInput: { + '11.5.0': 'data-testid share panel internally scale factor input', + }, + generateImageButton: { + '11.5.0': 'data-testid share panel internally generate image button', + }, + downloadImageButton: { + '11.5.0': 'data-testid share panel internally download image button', + }, + }, }, ShareExternally: { container: { diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index d576a093b55..4268276f60d 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -19,7 +19,7 @@ import { contextSrv } from 'app/core/services/context_srv'; import { getMessageFromError } from 'app/core/utils/errors'; import { getCreateAlertInMenuAvailability } from 'app/features/alerting/unified/utils/access-control'; import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form'; -import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; +import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; import { InspectTab } from 'app/features/inspector/types'; import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils'; @@ -90,6 +90,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) { iconClassName: 'link', shortcut: 'p u', onClick: () => { + DashboardInteractions.sharingCategoryClicked({ + item: shareDashboardType.link, + shareResource: getTrackingSource(panel?.getRef()), + }); + const drawer = new ShareDrawer({ shareView: shareDashboardType.link, panelRef: panel.getRef(), @@ -103,6 +108,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) { iconClassName: 'arrow', shortcut: 'p e', onClick: () => { + DashboardInteractions.sharingCategoryClicked({ + item: shareDashboardType.embed, + shareResource: getTrackingSource(panel.getRef()), + }); + const drawer = new ShareDrawer({ shareView: shareDashboardType.embed, panelRef: panel.getRef(), @@ -122,6 +132,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) { iconClassName: 'camera', shortcut: 'p s', onClick: () => { + DashboardInteractions.sharingCategoryClicked({ + item: shareDashboardType.snapshot, + shareResource: getTrackingSource(panel.getRef()), + }); + const drawer = new ShareDrawer({ shareView: shareDashboardType.snapshot, panelRef: panel.getRef(), diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx index 02436ca3879..6151a44fa5f 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx @@ -1,7 +1,7 @@ -import { dateTime } from '@grafana/data'; +import { dateTime, UrlQueryMap } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; -import { SceneComponentProps, SceneObjectBase, SceneObjectRef, VizPanel, sceneGraph } from '@grafana/scenes'; +import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes'; import { TimeZone } from '@grafana/schema'; import { Alert, ClipboardButton, Field, FieldSet, Icon, Input, Switch } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; @@ -14,6 +14,7 @@ import { getDashboardUrl } from '../utils/urlBuilders'; import { getDashboardSceneFor } from '../utils/utils'; import { SceneShareTabState, ShareView } from './types'; + export interface ShareLinkTabState extends SceneShareTabState, ShareOptions { panelRef?: SceneObjectRef; } @@ -55,7 +56,7 @@ export class ShareLinkTab extends SceneObjectBase implements this.onThemeChange = this.onThemeChange.bind(this); } - async buildUrl() { + buildUrl = async (queryOptions?: UrlQueryMap) => { this.setState({ isBuildUrlLoading: true }); const { panelRef, useLockedTime: useAbsoluteTimeRange, useShortUrl, selectedTheme } = this.state; const dashboard = getDashboardSceneFor(this); @@ -83,7 +84,7 @@ export class ShareLinkTab extends SceneObjectBase implements const imageUrl = getDashboardUrl({ uid: dashboard.state.uid, currentQueryParams: location.search, - updateQuery: { ...urlParamsUpdate, panelId: panel?.state.key }, + updateQuery: { ...urlParamsUpdate, ...queryOptions, panelId: panel?.state.key }, absolute: true, soloRoute: true, render: true, @@ -91,7 +92,7 @@ export class ShareLinkTab extends SceneObjectBase implements }); this.setState({ shareUrl, imageUrl, isBuildUrlLoading: false }); - } + }; public getTabLabel() { return t('share-modal.tab-title.link', 'Link'); diff --git a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx new file mode 100644 index 00000000000..ac5daf5eac4 --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; + +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { config, setPluginImportUtils } from '@grafana/runtime'; +import { SceneTimeRange, VizPanel } from '@grafana/scenes'; + +import { userEvent } from '../../../../../test/test-utils'; +import { DashboardScene } from '../../scene/DashboardScene'; +import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager'; +import { activateFullSceneTree } from '../../utils/test-utils'; + +import { SharePanelInternally } from './SharePanelInternally'; + +setPluginImportUtils({ + importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: (id: string) => undefined, +}); + +const selector = e2eSelectors.pages.ShareDashboardDrawer.ShareInternally.SharePanel; + +describe('SharePanelInternally', () => { + it('should disable all image generation inputs when renderer is not available', async () => { + config.rendererAvailable = false; + buildAndRenderScenario(); + + expect(await screen.findByTestId(selector.preview)).toBeInTheDocument(); + [ + selector.widthInput, + selector.heightInput, + selector.scaleFactorInput, + selector.generateImageButton, + selector.downloadImageButton, + ].forEach((selector) => { + expect(screen.getByTestId(selector)).toBeDisabled(); + }); + }); + + it('should enable all image generation inputs when renderer is available', async () => { + config.rendererAvailable = true; + buildAndRenderScenario(); + + expect(await screen.findByTestId(selector.preview)).toBeInTheDocument(); + [selector.widthInput, selector.heightInput, selector.scaleFactorInput].forEach((selector) => { + expect(screen.getByTestId(selector)).toBeEnabled(); + }); + + await userEvent.type(screen.getByTestId(selector.widthInput), '1000'); + await userEvent.type(screen.getByTestId(selector.widthInput), '2000'); + expect(screen.getByTestId(selector.generateImageButton)).toBeEnabled(); + expect(screen.getByTestId(selector.downloadImageButton)).toBeDisabled(); + }); +}); + +function buildAndRenderScenario() { + const panel = new VizPanel({ + title: 'Panel A', + pluginId: 'table', + key: 'panel-12', + }); + const tab = new SharePanelInternally({ panelRef: panel.getRef() }); + const scene = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + $timeRange: new SceneTimeRange({}), + body: DefaultGridLayoutManager.fromVizPanels([panel]), + overlay: tab, + }); + + activateFullSceneTree(scene); + + render(); + + return tab; +} diff --git a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx index 11f9af59a48..0dbf038cbba 100644 --- a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx +++ b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelInternally.tsx @@ -3,13 +3,15 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; import { SceneComponentProps } from '@grafana/scenes'; -import { Alert, ClipboardButton, Divider, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Alert, ClipboardButton, Divider, Stack, Text, useStyles2 } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; import { getDashboardSceneFor } from '../../utils/utils'; import ShareInternallyConfiguration from '../ShareInternallyConfiguration'; import { ShareLinkTab, ShareLinkTabState } from '../ShareLinkTab'; +import { SharePanelPreview } from './SharePanelPreview'; + export class SharePanelInternally extends ShareLinkTab { static Component = SharePanelInternallyRenderer; @@ -24,56 +26,44 @@ export class SharePanelInternally extends ShareLinkTab { function SharePanelInternallyRenderer({ model }: SceneComponentProps) { const styles = useStyles2(getStyles); - const { useLockedTime, useShortUrl, selectedTheme, isBuildUrlLoading, imageUrl } = model.useState(); + const { useLockedTime, useShortUrl, selectedTheme, isBuildUrlLoading, imageUrl, panelRef } = model.useState(); + + const panelTitle = panelRef?.resolve().state.title; const dashboard = getDashboardSceneFor(model); const isDashboardSaved = Boolean(dashboard.state.uid); return ( - <> -
- - - Create a personalized, direct link to share your panel within your organization, with the following - customization settings: - - +
+ + + Create a personalized, direct link to share your panel within your organization, with the following + customization settings: + + +
+ + + Copy link +
- model.onToggleLockedTime()} - useShortUrl={useShortUrl} - onUrlShorten={() => model.onUrlShorten()} - selectedTheme={selectedTheme} - onChangeTheme={(t) => model.onThemeChange(t)} - isLoading={isBuildUrlLoading} - /> - + -
- - - Copy link - - - Render image - - -
{!isDashboardSaved && ( @@ -101,16 +91,20 @@ function SharePanelInternallyRenderer({ model }: SceneComponentProps )} +
- +
); } const getStyles = (theme: GrafanaTheme2) => ({ - configDescription: css({ - marginBottom: theme.spacing(2), - }), - buttonsContainer: css({ + configurationContainer: css({ marginTop: theme.spacing(2), }), }); diff --git a/public/app/features/dashboard-scene/sharing/panel-share/SharePanelPreview.tsx b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelPreview.tsx new file mode 100644 index 00000000000..94345914fdb --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/panel-share/SharePanelPreview.tsx @@ -0,0 +1,228 @@ +import { css } from '@emotion/css'; +import saveAs from 'file-saver'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { useAsyncFn } from 'react-use'; +import { lastValueFrom } from 'rxjs'; + +import { GrafanaTheme2, UrlQueryMap } from '@grafana/data'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { config, getBackendSrv, isFetchError } from '@grafana/runtime'; +import { Alert, Button, Field, FieldSet, Icon, Input, LoadingBar, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { DashboardInteractions } from '../../utils/interactions'; + +type ImageSettingsForm = { + width: number; + height: number; + scaleFactor: number; +}; + +type Props = { + title: string; + buildUrl: (urlParams: UrlQueryMap) => void; + imageUrl: string; + disabled: boolean; + theme: string; +}; + +const selector = e2eSelectors.pages.ShareDashboardDrawer.ShareInternally.SharePanel; + +export function SharePanelPreview({ title, imageUrl, buildUrl, disabled, theme }: Props) { + const styles = useStyles2(getStyles); + + const { + handleSubmit, + register, + watch, + formState: { errors, isValid }, + } = useForm({ + mode: 'onChange', + defaultValues: { + width: 1000, + height: 500, + scaleFactor: 1, + }, + }); + + useEffect(() => { + buildUrl({ width: watch('width'), height: watch('height'), scale: watch('scaleFactor') }); + }, [buildUrl, watch]); + + const [{ loading, value: image, error }, renderImage] = useAsyncFn(async () => { + const { width, height, scaleFactor } = watch(); + DashboardInteractions.generatePanelImageClicked({ + width, + height, + scaleFactor, + theme, + shareResource: 'panel', + }); + const response = await lastValueFrom(getBackendSrv().fetch({ url: imageUrl, responseType: 'blob' })); + return new Blob([response.data], { type: 'image/png' }); + }, [imageUrl, watch('width'), watch('height'), watch('scaleFactor'), theme]); + + const onDownloadImageClick = () => { + DashboardInteractions.downloadPanelImageClicked({ shareResource: 'panel' }); + saveAs(image!, `${title}.png`); + }; + + const onChange = () => { + buildUrl({ width: watch('width'), height: watch('height'), scale: watch('scaleFactor') }); + }; + + return ( +
+ + + Panel preview + +
+
+ + Image settings + + + + + + } + > + + + + + + + + + + + + + + + +
+
+ {loading && ( +
+ +
+ {title || ''} +
+
+ )} + {image && !loading && panel-preview-img} + {error && !loading && ( + + {isFetchError(error) + ? error.statusText + : t('link.share-panel.render-image-error-description', 'An error occurred when generating the image')} + + )} +
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + imageConfigurationField: css({ + flex: 1, + }), + image: css({ + maxWidth: '100%', + width: 'max-content', + }), + imageLoadingContainer: css({ + maxWidth: '100%', + height: 362, + border: `1px solid ${theme.components.input.borderColor}`, + padding: theme.spacing(1), + }), +}); diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts index 6ed95fe6cb8..988d05c9e12 100644 --- a/public/app/features/dashboard-scene/utils/interactions.ts +++ b/public/app/features/dashboard-scene/utils/interactions.ts @@ -45,6 +45,12 @@ export const DashboardInteractions = { embedSnippetCopy: (properties?: Record) => { reportDashboardInteraction('sharing_embed_copy_clicked', properties); }, + generatePanelImageClicked: (properties?: Record) => { + reportDashboardInteraction('sharing_link_generate_image_clicked', properties); + }, + downloadPanelImageClicked: (properties?: Record) => { + reportDashboardInteraction('sharing_link_download_image_clicked', properties); + }, publishSnapshotClicked: (properties?: Record) => { reportDashboardInteraction('sharing_snapshot_publish_clicked', properties); }, diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.ts b/public/app/features/dashboard-scene/utils/urlBuilders.ts index 4d4104f100d..30d4808e302 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.ts @@ -50,8 +50,8 @@ export function getDashboardUrl(options: DashboardUrlOptions) { options.updateQuery = { ...options.updateQuery, - width: 1000, - height: 500, + width: options.updateQuery?.width || 1000, + height: options.updateQuery?.height || 500, tz: options.timeZone, }; } diff --git a/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx b/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx index 31bdbc6a894..5101db70c7b 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx @@ -1,8 +1,7 @@ import { FormEvent, useEffect, useState } from 'react'; -import { useEffectOnce } from 'react-use'; import { RawTimeRange, TimeRange } from '@grafana/data'; -import { config, reportInteraction } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; import { Button, ClipboardButton, Field, Label, Modal, Stack, Switch, TextArea } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; @@ -24,10 +23,6 @@ export function ShareEmbed({ panel, dashboard, range, onCancelClick, buildIframe const [selectedTheme, setSelectedTheme] = useState('current'); const [iframeHtml, setIframeHtml] = useState(''); - useEffectOnce(() => { - reportInteraction('grafana_dashboards_embed_share_viewed', { shareResource: getTrackingSource(panel) }); - }); - useEffect(() => { const newIframeHtml = buildIframe(useCurrentTimeRange, dashboard.uid, selectedTheme, panel, range); setIframeHtml(newIframeHtml); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 92f6777debd..db81976f539 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1715,7 +1715,10 @@ }, "share-panel": { "config-description": "Create a personalized, direct link to share your panel within your organization, with the following customization settings:", - "render-image": "Render image" + "download-image": "Download image", + "render-image": "Generate image", + "render-image-error": "Failed to render panel image", + "render-image-error-description": "An error occurred when generating the image" } }, "login": { @@ -3033,6 +3036,27 @@ "create-button": "Create library panel" } }, + "share-panel-image": { + "preview": { + "title": "Panel preview" + }, + "settings": { + "height-label": "Height", + "height-min": "Height must be equal or greater than 1", + "height-placeholder": "500", + "height-required": "Height is required", + "max-warning": "Setting maximums are limited by the image renderer service", + "scale-factor-label": "Scale factor", + "scale-factor-min": "Scale factor must be equal or greater than 1", + "scale-factor-placeholder": "1", + "scale-factor-required": "Scale factor is required", + "title": "Image settings", + "width-label": "Width", + "width-min": "Width must be equal or greater than 1", + "width-placeholder": "1000", + "width-required": "Width is required" + } + }, "share-playlist": { "checkbox-description": "Panel heights will be adjusted to fit screen size", "checkbox-label": "Autofit", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 5dde76ff509..cff6f710dd9 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1715,7 +1715,10 @@ }, "share-panel": { "config-description": "Cřęäŧę ä pęřşőʼnäľįžęđ, đįřęčŧ ľįʼnĸ ŧő şĥäřę yőūř päʼnęľ ŵįŧĥįʼn yőūř őřģäʼnįžäŧįőʼn, ŵįŧĥ ŧĥę ƒőľľőŵįʼnģ čūşŧőmįžäŧįőʼn şęŧŧįʼnģş:", - "render-image": "Ŗęʼnđęř įmäģę" + "download-image": "Đőŵʼnľőäđ įmäģę", + "render-image": "Ğęʼnęřäŧę įmäģę", + "render-image-error": "Fäįľęđ ŧő řęʼnđęř päʼnęľ įmäģę", + "render-image-error-description": "Åʼn ęřřőř őččūřřęđ ŵĥęʼn ģęʼnęřäŧįʼnģ ŧĥę įmäģę" } }, "login": { @@ -3033,6 +3036,27 @@ "create-button": "Cřęäŧę ľįþřäřy päʼnęľ" } }, + "share-panel-image": { + "preview": { + "title": "Päʼnęľ přęvįęŵ" + }, + "settings": { + "height-label": "Ħęįģĥŧ", + "height-min": "Ħęįģĥŧ mūşŧ þę ęqūäľ őř ģřęäŧęř ŧĥäʼn 1", + "height-placeholder": "500", + "height-required": "Ħęįģĥŧ įş řęqūįřęđ", + "max-warning": "Ŝęŧŧįʼnģ mäχįmūmş äřę ľįmįŧęđ þy ŧĥę įmäģę řęʼnđęřęř şęřvįčę", + "scale-factor-label": "Ŝčäľę ƒäčŧőř", + "scale-factor-min": "Ŝčäľę ƒäčŧőř mūşŧ þę ęqūäľ őř ģřęäŧęř ŧĥäʼn 1", + "scale-factor-placeholder": "1", + "scale-factor-required": "Ŝčäľę ƒäčŧőř įş řęqūįřęđ", + "title": "Ĩmäģę şęŧŧįʼnģş", + "width-label": "Ŵįđŧĥ", + "width-min": "Ŵįđŧĥ mūşŧ þę ęqūäľ őř ģřęäŧęř ŧĥäʼn 1", + "width-placeholder": "1000", + "width-required": "Ŵįđŧĥ įş řęqūįřęđ" + } + }, "share-playlist": { "checkbox-description": "Päʼnęľ ĥęįģĥŧş ŵįľľ þę äđĵūşŧęđ ŧő ƒįŧ şčřęęʼn şįžę", "checkbox-label": "Åūŧőƒįŧ", From 3a8113889a7ed4dc67e1464c0a74de74d40d5145 Mon Sep 17 00:00:00 2001 From: Konstantin Kornienko <50887992+konstantin-kornienko@users.noreply.github.com> Date: Wed, 18 Dec 2024 23:27:07 +0300 Subject: [PATCH 12/32] Annotations: Fix `min step` parameter interpolation in Prometheus annotations query (#97734) * Fix `min step` parameter interpolation in prometheus annotations query Looks like the step is interpolated in annotationQuery, but not interpolated in processAnnotationResponse. Fixing that. * Update packages/grafana-prometheus/src/datasource.ts Co-authored-by: Brendan O'Handley * Added test for non-default step * modified tests * apply prettier manually --------- Co-authored-by: Brendan O'Handley --- .../grafana-prometheus/src/datasource.test.ts | 31 ++++++++++++++++++- packages/grafana-prometheus/src/datasource.ts | 6 +++- .../src/test/__mocks__/datasource.ts | 4 +++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/grafana-prometheus/src/datasource.test.ts b/packages/grafana-prometheus/src/datasource.test.ts index 664206c9f1f..a8da9fa6ec0 100644 --- a/packages/grafana-prometheus/src/datasource.test.ts +++ b/packages/grafana-prometheus/src/datasource.test.ts @@ -1051,9 +1051,13 @@ describe('PrometheusDatasource2', () => { }, } as unknown as AnnotationQueryRequest; - async function runAnnotationQuery(data: number[][]) { + async function runAnnotationQuery(data: number[][], overrideStep?: string) { let response = createAnnotationResponse(); response.data.results['X'].frames[0].data.values = data; + if (overrideStep) { + const meta = response.data.results['X'].frames[0].schema.meta; + meta.executedQueryString = meta.executedQueryString.replace('1m0s', overrideStep); + } options.annotation.useValueForTime = false; fetchMock.mockImplementation(() => of(response)); @@ -1093,6 +1097,31 @@ describe('PrometheusDatasource2', () => { const results = await runAnnotationQuery([[2 * 60000], [1]]); expect(results.map((result) => [result.time, result.timeEnd])).toEqual([[120000, 120000]]); }); + + describe('should group annotations over wider range when the step grows larger', () => { + const data: number[][] = [ + [1 * 120000, 2 * 120000, 3 * 120000, 4 * 120000, 5 * 120000, 6 * 120000], + [1, 1, 0, 0, 1, 1], + ]; + + it('should not group annotations with the default step', async () => { + const results = await runAnnotationQuery(data); + expect(results.map((result) => [result.time, result.timeEnd])).toEqual([ + [120000, 120000], + [240000, 240000], + [600000, 600000], + [720000, 720000], + ]); + }); + + it('should group annotations with larger step', async () => { + const results = await runAnnotationQuery(data, '2m0s'); + expect(results.map((result) => [result.time, result.timeEnd])).toEqual([ + [120000, 240000], + [600000, 720000], + ]); + }); + }); }); describe('with template variables', () => { diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index a2e4b2bd953..c7653f5702f 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -537,7 +537,11 @@ export class PrometheusDatasource const annotation = options.annotation; const { tagKeys = '', titleFormat = '', textFormat = '' } = annotation; - const step = rangeUtil.intervalToSeconds(annotation.step || ANNOTATION_QUERY_STEP_DEFAULT) * 1000; + const input = frames[0].meta?.executedQueryString || ''; + const regex = /Step:\s*([\d\w]+)/; + const match = input.match(regex); + const stepValue = match ? match[1] : null; + const step = rangeUtil.intervalToSeconds(stepValue || ANNOTATION_QUERY_STEP_DEFAULT) * 1000; const tagKeysArray = tagKeys.split(','); const eventList: AnnotationEvent[] = []; diff --git a/packages/grafana-prometheus/src/test/__mocks__/datasource.ts b/packages/grafana-prometheus/src/test/__mocks__/datasource.ts index e4e4fb21e3c..6befc818c38 100644 --- a/packages/grafana-prometheus/src/test/__mocks__/datasource.ts +++ b/packages/grafana-prometheus/src/test/__mocks__/datasource.ts @@ -108,6 +108,10 @@ export function createAnnotationResponse() { schema: { name: 'bar', refId: 'X', + meta: { + typeVersion: [0, 0], + executedQueryString: 'Expr: ALERTS{}\nStep: 1m0s', + }, fields: [ { name: 'Time', From c9c77a6a6c817ddfa483754940b066564279d83c Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:43:45 -0500 Subject: [PATCH 13/32] Docs: Hide cost info and add note about future cost (#97361) Co-authored-by: Thanos Karachalios <47744721+thanos-karachalios@users.noreply.github.com> --- .../share-dashboards-panels/shared-dashboards/index.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md b/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md index bb98216d44b..43f71608d8f 100644 --- a/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md +++ b/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md @@ -77,11 +77,17 @@ This feature was previously called **email sharing**. Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud). {{< /admonition >}} -{{< docs/private-preview product="Sharing externally with specific people" >}} + + +{{< admonition type="note" >}} +Sharing externally with specific people is currently in [private preview](https://grafana.com/docs/release-life-cycle/#private-preview). Please contact support to have this feature enabled. + +This feature will incur a cost once it is promoted to general availability. +{{< /admonition >}} To share a dashboard with specific external users, you can send them a link by email. Use this option when you only want to share your dashboard with specific people. When you share dashboards by email, recipients receive a one-time use link that's valid for **one hour**. Once the link is used, the viewer has access to the shared dashboard for **30 days**. -When you share a dashboard with an email link, your organization is billed per user, regardless of how many dashboards are shared. Grafana bills monthly per user until access is revoked. + To share a dashboard with specific people, follow these steps: From 721c50a304588ebd7cea76e301ec0f68a5a55d68 Mon Sep 17 00:00:00 2001 From: Nick Richmond <5732000+NWRichmond@users.noreply.github.com> Date: Wed, 18 Dec 2024 16:31:08 -0500 Subject: [PATCH 14/32] Prometheus: Improve handling of special chars in label values (#96067) * fix: handling of special chars * docs: add clarity * fix: escaping * refactor: put changes behind new feature toggle * docs: use consistent comment style * refactor: rename feature toggle for brevity * use single quotes * fix unit tests * remove redundant json entry * fix: keep all changes behind feature toggle * fix: support builder mode * fix: don't escape when using regex operators * fix: code mode label values completions with special chars * refactor: remove unneeded changes * move feature toggle up so new changes from main won't conflict with ours * fix: escape label values in metric select scene * refactor: ensure changes are behind feature toggle --------- Co-authored-by: ismail simsek --- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + .../completions.test.ts | 164 +++++++++ .../monaco-completion-provider/completions.ts | 8 +- .../grafana-prometheus/src/datasource.test.ts | 330 +++++++++++++----- packages/grafana-prometheus/src/datasource.ts | 39 ++- .../shared/LokiAndPromQueryModellerBase.ts | 10 +- pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 13 + .../app/features/trails/MetricSelect/api.ts | 14 +- public/app/features/trails/otel/util.ts | 18 +- public/app/features/trails/otel/utils.test.ts | 12 +- 14 files changed, 513 insertions(+), 109 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index afe3cc88be5..5ce3a34a4e6 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -222,6 +222,7 @@ Experimental features might be changed or removed without prior notice. | `userStorageAPI` | Enables the user storage API | | `dashboardSchemaV2` | Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code. | | `playlistsWatcher` | Enables experimental watcher for playlists | +| `prometheusSpecialCharsInLabelValues` | Adds support for quotes and special characters in label values for Prometheus queries | | `enableExtensionsAdminPage` | Enables the extension admin page regardless of development mode | | `enableSCIM` | Enables SCIM support for user and group management | | `crashDetection` | Enables browser crash detection reporting to Faro. | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 169a99dfc6f..7783296f507 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -232,6 +232,7 @@ export interface FeatureToggles { playlistsWatcher?: boolean; passwordlessMagicLinkAuthentication?: boolean; exploreMetricsRelatedLogs?: boolean; + prometheusSpecialCharsInLabelValues?: boolean; enableExtensionsAdminPage?: boolean; zipkinBackendMigration?: boolean; enableSCIM?: boolean; diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts index 32bfcb14efc..7a97ba98740 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts @@ -274,3 +274,167 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat expect(completions.length).toBeLessThanOrEqual(expectedCompletionsCount); }); }); + +describe('Label value completions', () => { + let dataProvider: DataProvider; + + beforeEach(() => { + dataProvider = { + getAllMetricNames: jest.fn(), + metricNamesToMetrics: jest.fn(), + getHistory: jest.fn(), + getLabelNames: jest.fn(), + getLabelValues: jest.fn().mockResolvedValue(['value1', 'value"2', 'value\\3', "value'4"]), + getSeriesLabels: jest.fn(), + getSeriesValues: jest.fn(), + monacoSettings: { + setInputInRange: jest.fn(), + inputInRange: '', + suggestionsIncomplete: false, + enableAutocompleteSuggestionsUpdate: jest.fn(), + }, + metricNamesSuggestionLimit: 100, + } as unknown as DataProvider; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('with prometheusSpecialCharsInLabelValues disabled', () => { + beforeEach(() => { + jest.replaceProperty(config, 'featureToggles', { + prometheusSpecialCharsInLabelValues: false, + }); + }); + + it('should not escape special characters when between quotes', async () => { + const situation: Situation = { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'testLabel', + betweenQuotes: true, + otherLabels: [], + }; + + const completions = await getCompletions(situation, dataProvider); + + expect(completions).toHaveLength(4); + expect(completions[0].insertText).toBe('value1'); + expect(completions[1].insertText).toBe('value"2'); + expect(completions[2].insertText).toBe('value\\3'); + expect(completions[3].insertText).toBe("value'4"); + }); + + it('should wrap in quotes but not escape special characters when not between quotes', async () => { + const situation: Situation = { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'testLabel', + betweenQuotes: false, + otherLabels: [], + }; + + const completions = await getCompletions(situation, dataProvider); + + expect(completions).toHaveLength(4); + expect(completions[0].insertText).toBe('"value1"'); + expect(completions[1].insertText).toBe('"value"2"'); + expect(completions[2].insertText).toBe('"value\\3"'); + expect(completions[3].insertText).toBe('"value\'4"'); + }); + }); + + describe('with prometheusSpecialCharsInLabelValues enabled', () => { + beforeEach(() => { + jest.replaceProperty(config, 'featureToggles', { + prometheusSpecialCharsInLabelValues: true, + }); + }); + + it('should escape special characters when between quotes', async () => { + const situation: Situation = { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'testLabel', + betweenQuotes: true, + otherLabels: [], + }; + + const completions = await getCompletions(situation, dataProvider); + + expect(completions).toHaveLength(4); + expect(completions[0].insertText).toBe('value1'); + expect(completions[1].insertText).toBe('value\\"2'); + expect(completions[2].insertText).toBe('value\\\\3'); + expect(completions[3].insertText).toBe("value'4"); + }); + + it('should wrap in quotes and escape special characters when not between quotes', async () => { + const situation: Situation = { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'testLabel', + betweenQuotes: false, + otherLabels: [], + }; + + const completions = await getCompletions(situation, dataProvider); + + expect(completions).toHaveLength(4); + expect(completions[0].insertText).toBe('"value1"'); + expect(completions[1].insertText).toBe('"value\\"2"'); + expect(completions[2].insertText).toBe('"value\\\\3"'); + expect(completions[3].insertText).toBe('"value\'4"'); + }); + }); + + describe('label value escaping edge cases', () => { + beforeEach(() => { + jest.replaceProperty(config, 'featureToggles', { + prometheusSpecialCharsInLabelValues: true, + }); + }); + + it('should handle empty values', async () => { + jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue(['']); + + const situation: Situation = { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'testLabel', + betweenQuotes: false, + otherLabels: [], + }; + + const completions = await getCompletions(situation, dataProvider); + expect(completions).toHaveLength(1); + expect(completions[0].insertText).toBe('""'); + }); + + it('should handle values with multiple special characters', async () => { + jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue(['test"\\value']); + + const situation: Situation = { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'testLabel', + betweenQuotes: true, + otherLabels: [], + }; + + const completions = await getCompletions(situation, dataProvider); + expect(completions).toHaveLength(1); + expect(completions[0].insertText).toBe('test\\"\\\\value'); + }); + + it('should handle non-string values', async () => { + jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue([123 as unknown as string]); + + const situation: Situation = { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'testLabel', + betweenQuotes: false, + otherLabels: [], + }; + + const completions = await getCompletions(situation, dataProvider); + expect(completions).toHaveLength(1); + expect(completions[0].insertText).toBe('"123"'); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts index 95c16738d55..1060c8df90a 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -3,6 +3,7 @@ import UFuzzy from '@leeoniya/ufuzzy'; import { config } from '@grafana/runtime'; +import { prometheusRegularEscape } from '../../../datasource'; import { escapeLabelValueInExactSelector } from '../../../language_utils'; import { FUNCTIONS } from '../../../promql'; @@ -208,10 +209,15 @@ async function getLabelValuesForMetricCompletions( return values.map((text) => ({ type: 'LABEL_VALUE', label: text, - insertText: betweenQuotes ? text : `"${text}"`, // FIXME: escaping strange characters? + insertText: formatLabelValueForCompletion(text, betweenQuotes), })); } +function formatLabelValueForCompletion(value: string, betweenQuotes: boolean): string { + const text = config.featureToggles.prometheusSpecialCharsInLabelValues ? prometheusRegularEscape(value) : value; + return betweenQuotes ? text : `"${text}"`; +} + export function getCompletions(situation: Situation, dataProvider: DataProvider): Promise { switch (situation.type) { case 'IN_DURATION': diff --git a/packages/grafana-prometheus/src/datasource.test.ts b/packages/grafana-prometheus/src/datasource.test.ts index a8da9fa6ec0..54cdf88f8c6 100644 --- a/packages/grafana-prometheus/src/datasource.test.ts +++ b/packages/grafana-prometheus/src/datasource.test.ts @@ -245,60 +245,118 @@ describe('PrometheusDatasource', () => { const DEFAULT_QUERY_EXPRESSION = 'metric{job="foo"} - metric'; const target: PromQuery = { expr: DEFAULT_QUERY_EXPRESSION, refId: 'A' }; - it('should not modify expression with no filters', async () => { - ds.query({ - interval: '15s', - range: getMockTimeRange(), - targets: [target], - } as DataQueryRequest); - const [result] = fetchMockCalledWith(fetchMock); - expect(result).toMatchObject({ expr: DEFAULT_QUERY_EXPRESSION }); + describe('with prometheusSpecialCharsInLabelValues disabled', () => { + beforeAll(() => { + config.featureToggles.prometheusSpecialCharsInLabelValues = false; + }); + + it('should not modify expression with no filters', async () => { + ds.query({ + interval: '15s', + range: getMockTimeRange(), + targets: [target], + } as DataQueryRequest); + const [result] = fetchMockCalledWith(fetchMock); + expect(result).toMatchObject({ expr: DEFAULT_QUERY_EXPRESSION }); + }); + + it('should add filters to expression', () => { + const filters = [ + { + key: 'k1', + operator: '=', + value: 'v1', + }, + { + key: 'k2', + operator: '!=', + value: 'v2', + }, + ]; + ds.query({ + interval: '15s', + range: getMockTimeRange(), + filters, + targets: [target], + } as DataQueryRequest); + const [result] = fetchMockCalledWith(fetchMock); + expect(result).toMatchObject({ expr: 'metric{job="foo", k1="v1", k2!="v2"} - metric{k1="v1", k2!="v2"}' }); + }); + + it('should add escaping if needed to regex filter expressions', () => { + const filters = [ + { + key: 'k1', + operator: '=~', + value: 'v.*', + }, + { + key: 'k2', + operator: '=~', + value: `v'.*`, + }, + ]; + ds.query({ + interval: '15s', + range: getMockTimeRange(), + filters, + targets: [target], + } as DataQueryRequest); + const [result] = fetchMockCalledWith(fetchMock); + expect(result).toMatchObject({ + expr: `metric{job="foo", k1=~"v.*", k2=~"v\\\\'.*"} - metric{k1=~"v.*", k2=~"v\\\\'.*"}`, + }); + }); }); - it('should add filters to expression', () => { - const filters = [ - { - key: 'k1', - operator: '=', - value: 'v1', - }, - { - key: 'k2', - operator: '!=', - value: 'v2', - }, - ]; - ds.query({ - interval: '15s', - range: getMockTimeRange(), - filters, - targets: [target], - } as DataQueryRequest); - const [result] = fetchMockCalledWith(fetchMock); - expect(result).toMatchObject({ expr: 'metric{job="foo", k1="v1", k2!="v2"} - metric{k1="v1", k2!="v2"}' }); - }); - it('should add escaping if needed to regex filter expressions', () => { - const filters = [ - { - key: 'k1', - operator: '=~', - value: 'v.*', - }, - { - key: 'k2', - operator: '=~', - value: `v'.*`, - }, - ]; - ds.query({ - interval: '15s', - range: getMockTimeRange(), - filters, - targets: [target], - } as DataQueryRequest); - const [result] = fetchMockCalledWith(fetchMock); - expect(result).toMatchObject({ - expr: `metric{job="foo", k1=~"v.*", k2=~"v\\\\'.*"} - metric{k1=~"v.*", k2=~"v\\\\'.*"}`, + describe('with prometheusSpecialCharsInLabelValues enabled', () => { + beforeAll(() => { + config.featureToggles.prometheusSpecialCharsInLabelValues = true; + }); + + it('should not modify expression with no filters', async () => { + ds.query({ + interval: '15s', + range: getMockTimeRange(), + targets: [target], + } as DataQueryRequest); + const [result] = fetchMockCalledWith(fetchMock); + expect(result).toMatchObject({ expr: DEFAULT_QUERY_EXPRESSION }); + }); + + it('should add escaping if needed to regex filter expressions', () => { + const filters = [ + { + key: 'k1', + operator: '=~', + value: 'v.*', + }, + { + key: 'k2', + operator: '=~', + value: `v'.*`, + }, + { + key: 'k3', + operator: '=~', + value: `v".*`, + }, + { + key: 'k4', + operator: '=~', + value: `\\v.*`, + }, + ]; + ds.query({ + interval: '15s', + range: getMockTimeRange(), + filters, + targets: [target], + } as DataQueryRequest); + const [result] = fetchMockCalledWith(fetchMock); + expect(result).toMatchObject({ + expr: `metric{job="foo", k1=~"v.*", k2=~"v'.*", k3=~"v\\".*", k4=~"\\\\v.*"} - metric{k1=~"v.*", k2=~"v'.*", k3=~"v\\".*", k4=~"\\\\v.*"}`, + }); }); }); }); @@ -470,56 +528,126 @@ describe('PrometheusDatasource', () => { }); describe('Prometheus regular escaping', () => { - it('should not escape non-string', () => { - expect(prometheusRegularEscape(12)).toEqual(12); + describe('with prometheusSpecialCharsInLabelValues disabled', () => { + beforeAll(() => { + config.featureToggles.prometheusSpecialCharsInLabelValues = false; + }); + + it('should not escape non-string', () => { + expect(prometheusRegularEscape(12)).toEqual(12); + }); + + it('should not escape strings without special characters', () => { + expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression'); + }); + + it('should escape single quotes', () => { + expect(prometheusRegularEscape("looking'glass")).toEqual("looking\\\\'glass"); + }); + + it('should escape backslashes', () => { + expect(prometheusRegularEscape('looking\\glass')).toEqual('looking\\\\glass'); + }); }); - it('should not escape simple string', () => { - expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression'); - }); + describe('with prometheusSpecialCharsInLabelValues enabled', () => { + beforeAll(() => { + config.featureToggles.prometheusSpecialCharsInLabelValues = true; + }); - it("should escape '", () => { - expect(prometheusRegularEscape("looking'glass")).toEqual("looking\\\\'glass"); - }); + it('should not escape non-string', () => { + expect(prometheusRegularEscape(12)).toEqual(12); + }); - it('should escape \\', () => { - expect(prometheusRegularEscape('looking\\glass')).toEqual('looking\\\\glass'); - }); + it('should not escape strings without special characters', () => { + expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression'); + }); - it('should escape multiple characters', () => { - expect(prometheusRegularEscape("'looking'glass'")).toEqual("\\\\'looking\\\\'glass\\\\'"); - }); + it('should not escape complete label matcher', () => { + expect(prometheusRegularEscape('job="grafana"')).toEqual('job="grafana"'); + expect(prometheusRegularEscape('job!="grafana"')).toEqual('job!="grafana"'); + expect(prometheusRegularEscape('job=~"grafana"')).toEqual('job=~"grafana"'); + expect(prometheusRegularEscape('job!~"grafana"')).toEqual('job!~"grafana"'); + }); - it('should escape multiple different characters', () => { - expect(prometheusRegularEscape("'loo\\king'glass'")).toEqual("\\\\'loo\\\\king\\\\'glass\\\\'"); + it('should not escape single quotes', () => { + expect(prometheusRegularEscape("looking'glass")).toEqual("looking'glass"); + }); + + it('should escape double quotes', () => { + expect(prometheusRegularEscape('looking"glass')).toEqual('looking\\"glass'); + }); + + it('should escape backslashes', () => { + expect(prometheusRegularEscape('looking\\glass')).toEqual('looking\\\\glass'); + }); + + it('should handle complete label matchers with escaped content', () => { + expect(prometheusRegularEscape('job="my\\"service"')).toEqual('job="my\\"service"'); + expect(prometheusRegularEscape('job="\\\\server"')).toEqual('job="\\\\server"'); + }); }); }); describe('Prometheus regexes escaping', () => { - it('should not escape simple string', () => { - expect(prometheusSpecialRegexEscape('cryptodepression')).toEqual('cryptodepression'); + describe('with prometheusSpecialCharsInLabelValues disabled', () => { + beforeAll(() => { + config.featureToggles.prometheusSpecialCharsInLabelValues = false; + }); + + it('should not escape strings without special characters', () => { + expect(prometheusSpecialRegexEscape('cryptodepression')).toEqual('cryptodepression'); + }); + + it('should escape special characters', () => { + expect(prometheusSpecialRegexEscape('looking{glass')).toEqual('looking\\\\{glass'); + expect(prometheusSpecialRegexEscape('looking$glass')).toEqual('looking\\\\$glass'); + expect(prometheusSpecialRegexEscape('looking\\glass')).toEqual('looking\\\\\\\\glass'); + expect(prometheusSpecialRegexEscape('looking|glass')).toEqual('looking\\\\|glass'); + }); + + it('should handle multiple special characters', () => { + expect(prometheusSpecialRegexEscape('+looking$glass?')).toEqual('\\\\+looking\\\\$glass\\\\?'); + }); }); - it('should escape $^*+?.()|\\', () => { - expect(prometheusSpecialRegexEscape("looking'glass")).toEqual("looking\\\\'glass"); - expect(prometheusSpecialRegexEscape('looking{glass')).toEqual('looking\\\\{glass'); - expect(prometheusSpecialRegexEscape('looking}glass')).toEqual('looking\\\\}glass'); - expect(prometheusSpecialRegexEscape('looking[glass')).toEqual('looking\\\\[glass'); - expect(prometheusSpecialRegexEscape('looking]glass')).toEqual('looking\\\\]glass'); - expect(prometheusSpecialRegexEscape('looking$glass')).toEqual('looking\\\\$glass'); - expect(prometheusSpecialRegexEscape('looking^glass')).toEqual('looking\\\\^glass'); - expect(prometheusSpecialRegexEscape('looking*glass')).toEqual('looking\\\\*glass'); - expect(prometheusSpecialRegexEscape('looking+glass')).toEqual('looking\\\\+glass'); - expect(prometheusSpecialRegexEscape('looking?glass')).toEqual('looking\\\\?glass'); - expect(prometheusSpecialRegexEscape('looking.glass')).toEqual('looking\\\\.glass'); - expect(prometheusSpecialRegexEscape('looking(glass')).toEqual('looking\\\\(glass'); - expect(prometheusSpecialRegexEscape('looking)glass')).toEqual('looking\\\\)glass'); - expect(prometheusSpecialRegexEscape('looking\\glass')).toEqual('looking\\\\\\\\glass'); - expect(prometheusSpecialRegexEscape('looking|glass')).toEqual('looking\\\\|glass'); - }); + describe('with prometheusSpecialCharsInLabelValues enabled', () => { + beforeAll(() => { + config.featureToggles.prometheusSpecialCharsInLabelValues = true; + }); - it('should escape multiple special characters', () => { - expect(prometheusSpecialRegexEscape('+looking$glass?')).toEqual('\\\\+looking\\\\$glass\\\\?'); + it('should not escape strings without special characters', () => { + expect(prometheusSpecialRegexEscape('cryptodepression')).toEqual('cryptodepression'); + }); + + it('should escape special characters', () => { + expect(prometheusSpecialRegexEscape('looking{glass')).toEqual('looking\\\\{glass'); + expect(prometheusSpecialRegexEscape('looking}glass')).toEqual('looking\\\\}glass'); + expect(prometheusSpecialRegexEscape('looking[glass')).toEqual('looking\\\\[glass'); + expect(prometheusSpecialRegexEscape('looking]glass')).toEqual('looking\\\\]glass'); + expect(prometheusSpecialRegexEscape('looking$glass')).toEqual('looking\\\\$glass'); + expect(prometheusSpecialRegexEscape('looking^glass')).toEqual('looking\\\\^glass'); + expect(prometheusSpecialRegexEscape('looking*glass')).toEqual('looking\\\\*glass'); + expect(prometheusSpecialRegexEscape('looking+glass')).toEqual('looking\\\\+glass'); + expect(prometheusSpecialRegexEscape('looking?glass')).toEqual('looking\\\\?glass'); + expect(prometheusSpecialRegexEscape('looking.glass')).toEqual('looking\\\\.glass'); + expect(prometheusSpecialRegexEscape('looking(glass')).toEqual('looking\\\\(glass'); + expect(prometheusSpecialRegexEscape('looking)glass')).toEqual('looking\\\\)glass'); + expect(prometheusSpecialRegexEscape('looking\\glass')).toEqual('looking\\\\\\\\glass'); + expect(prometheusSpecialRegexEscape('looking|glass')).toEqual('looking\\\\|glass'); + }); + + it('should escape double quotes with special regex escaping', () => { + expect(prometheusSpecialRegexEscape('looking"glass')).toEqual('looking\\\\\\"glass'); + }); + + it('should handle multiple special characters', () => { + expect(prometheusSpecialRegexEscape('+looking$glass?')).toEqual('\\\\+looking\\\\$glass\\\\?'); + }); + + it('should handle mixed quotes and special characters', () => { + expect(prometheusSpecialRegexEscape('+looking"$glass?')).toEqual('\\\\+looking\\\\\\"\\\\$glass\\\\?'); + }); }); }); @@ -548,9 +676,27 @@ describe('PrometheusDatasource', () => { }; }); - describe('and value is a string', () => { - it('should only escape single quotes', () => { - expect(ds.interpolateQueryExpr("abc'$^*{}[]+?.()|", customVariable)).toEqual("abc\\\\'$^*{}[]+?.()|"); + describe('with prometheusSpecialCharsInLabelValues disabled', () => { + beforeAll(() => { + config.featureToggles.prometheusSpecialCharsInLabelValues = false; + }); + + describe('and value is a string', () => { + it('should escape single quotes', () => { + expect(ds.interpolateQueryExpr("abc'$^*{}[]+?.()|", customVariable)).toEqual("abc\\\\'$^*{}[]+?.()|"); + }); + }); + }); + + describe('with prometheusSpecialCharsInLabelValues enabled', () => { + beforeAll(() => { + config.featureToggles.prometheusSpecialCharsInLabelValues = true; + }); + + describe('and value is a string', () => { + it('should only escape double quotes and backslashes', () => { + expect(ds.interpolateQueryExpr('abc\'"$^*{}[]+?.()|\\', customVariable)).toEqual('abc\'\\"$^*{}[]+?.()|\\\\'); + }); }); }); diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index c7653f5702f..8ece500ac12 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -1046,13 +1046,46 @@ export function extractRuleMappingFromGroups(groups: RawRecordingRules[]): RuleQ ); } -// NOTE: these two functions are very similar to the escapeLabelValueIn* functions +// NOTE: these two functions are similar to the escapeLabelValueIn* functions // in language_utils.ts, but they are not exactly the same algorithm, and we found // no way to reuse one in the another or vice versa. export function prometheusRegularEscape(value: T) { - return typeof value === 'string' ? value.replace(/\\/g, '\\\\').replace(/'/g, "\\\\'") : value; + if (typeof value !== 'string') { + return value; + } + + if (config.featureToggles.prometheusSpecialCharsInLabelValues) { + // if the string looks like a complete label matcher (e.g. 'job="grafana"' or 'job=~"grafana"'), + // don't escape the encapsulating quotes + if (/^\w+(=|!=|=~|!~)".*"$/.test(value)) { + return value; + } + + return value + .replace(/\\/g, '\\\\') // escape backslashes + .replace(/"/g, '\\"'); // escape double quotes + } + + // classic behavior + return value + .replace(/\\/g, '\\\\') // escape backslashes + .replace(/'/g, "\\\\'"); // escape single quotes } export function prometheusSpecialRegexEscape(value: T) { - return typeof value === 'string' ? value.replace(/\\/g, '\\\\\\\\').replace(/[$^*{}\[\]\'+?.()|]/g, '\\\\$&') : value; + if (typeof value !== 'string') { + return value; + } + + if (config.featureToggles.prometheusSpecialCharsInLabelValues) { + return value + .replace(/\\/g, '\\\\\\\\') // escape backslashes + .replace(/"/g, '\\\\\\"') // escape double quotes + .replace(/[$^*{}\[\]\'+?.()|]/g, '\\\\$&'); // escape regex metacharacters + } + + // classic behavior + return value + .replace(/\\/g, '\\\\\\\\') // escape backslashes + .replace(/[$^*{}\[\]+?.()|]/g, '\\\\$&'); // escape regex metacharacters } diff --git a/packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts b/packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts index 872973b4887..1d06ed7edac 100644 --- a/packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts +++ b/packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts @@ -1,6 +1,8 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/shared/LokiAndPromQueryModellerBase.ts import { Registry } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { prometheusRegularEscape } from '../../datasource'; import { PromVisualQueryOperationCategory } from '../types'; import { QueryBuilderLabelFilter, QueryBuilderOperation, QueryBuilderOperationDef, VisualQueryModeller } from './types'; @@ -89,7 +91,13 @@ export abstract class LokiAndPromQueryModellerBase implements VisualQueryModelle expr += ', '; } - expr += `${filter.label}${filter.op}"${filter.value}"`; + let labelValue = filter.value; + const usingRegexOperator = filter.op === '=~' || filter.op === '!~'; + + if (config.featureToggles.prometheusSpecialCharsInLabelValues && !usingRegexOperator) { + labelValue = prometheusRegularEscape(labelValue); + } + expr += `${filter.label}${filter.op}"${labelValue}"`; } return expr + `}`; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index e79a97df92f..ddde412a65e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1608,6 +1608,13 @@ var ( FrontendOnly: true, HideFromDocs: true, }, + { + Name: "prometheusSpecialCharsInLabelValues", + Description: "Adds support for quotes and special characters in label values for Prometheus queries", + FrontendOnly: true, + Stage: FeatureStageExperimental, + Owner: grafanaObservabilityMetricsSquad, + }, { Name: "enableExtensionsAdminPage", Description: "Enables the extension admin page regardless of development mode", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 84416308b00..92c6b16ce16 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -213,6 +213,7 @@ dashboardSchemaV2,experimental,@grafana/dashboards-squad,false,false,true playlistsWatcher,experimental,@grafana/grafana-app-platform-squad,false,true,false passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true +prometheusSpecialCharsInLabelValues,experimental,@grafana/observability-metrics,false,false,true enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false zipkinBackendMigration,GA,@grafana/oss-big-tent,false,false,false enableSCIM,experimental,@grafana/identity-access-team,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 42843a4cf75..72fb3c04379 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -863,6 +863,10 @@ const ( // Display Related Logs in Explore Metrics FlagExploreMetricsRelatedLogs = "exploreMetricsRelatedLogs" + // FlagPrometheusSpecialCharsInLabelValues + // Adds support for quotes and special characters in label values for Prometheus queries + FlagPrometheusSpecialCharsInLabelValues = "prometheusSpecialCharsInLabelValues" + // FlagEnableExtensionsAdminPage // Enables the extension admin page regardless of development mode FlagEnableExtensionsAdminPage = "enableExtensionsAdminPage" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 7a4396aad85..6827fab8a5f 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2931,6 +2931,19 @@ "codeowner": "@grafana/observability-metrics" } }, + { + "metadata": { + "name": "prometheusSpecialCharsInLabelValues", + "resourceVersion": "1734047568531", + "creationTimestamp": "2024-12-12T23:52:48Z" + }, + "spec": { + "description": "Adds support for quotes and special characters in label values for Prometheus queries", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics", + "frontend": true + } + }, { "metadata": { "name": "prometheusUsesCombobox", diff --git a/public/app/features/trails/MetricSelect/api.ts b/public/app/features/trails/MetricSelect/api.ts index c6d77c6958f..2d499efc3c0 100644 --- a/public/app/features/trails/MetricSelect/api.ts +++ b/public/app/features/trails/MetricSelect/api.ts @@ -1,5 +1,6 @@ import { AdHocVariableFilter, RawTimeRange, Scope } from '@grafana/data'; import { getPrometheusTime } from '@grafana/prometheus/src/language_utils'; +import { PromQueryModeller } from '@grafana/prometheus/src/querybuilder/PromQueryModeller'; import { config, getBackendSrv } from '@grafana/runtime'; import { limitOtelMatchTerms } from '../otel/util'; @@ -7,6 +8,8 @@ import { callSuggestionsApi, SuggestionsResponse } from '../utils'; const LIMIT_REACHED = 'results truncated due to limit'; +const queryModeller = new PromQueryModeller(); + export async function getMetricNames( dataSourceUid: string, timeRange: RawTimeRange, @@ -31,7 +34,11 @@ export async function getMetricNamesWithoutScopes( instances: string[], limit?: number ) { - const matchTerms = adhocFilters.map((filter) => `${filter.key}${filter.operator}"${filter.value}"`); + const matchTerms = config.featureToggles.prometheusSpecialCharsInLabelValues + ? adhocFilters.map((filter) => + removeBrackets(queryModeller.renderLabels([{ label: filter.key, op: filter.operator, value: filter.value }])) + ) + : adhocFilters.map((filter) => `${filter.key}${filter.operator}"${filter.value}"`); let missingOtelTargets = false; if (jobs.length > 0 && instances.length > 0) { @@ -99,3 +106,8 @@ export async function getMetricNamesWithScopes( missingOtelTargets: false, }; } + +function removeBrackets(input: string): string { + const match = input.match(/^\{(.*)\}$/); // extract the content inside the brackets + return match?.[1] ?? ''; +} diff --git a/public/app/features/trails/otel/util.ts b/public/app/features/trails/otel/util.ts index f4cdae14d36..c8633e2df95 100644 --- a/public/app/features/trails/otel/util.ts +++ b/public/app/features/trails/otel/util.ts @@ -1,4 +1,5 @@ import { MetricFindValue } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { AdHocFiltersVariable, ConstantVariable, CustomVariable, sceneGraph, SceneObject } from '@grafana/scenes'; import { DataTrail } from '../DataTrail'; @@ -121,6 +122,9 @@ export function getOtelResourcesObject(scene: SceneObject, firstQueryVal?: strin // start with the deployment environment let allFilters = `deployment_environment${op}"${val}"`; + if (config.featureToggles.prometheusSpecialCharsInLabelValues) { + allFilters = `deployment_environment${op}'${val}'`; + } let allLabels = 'deployment_environment'; // add the other OTEL resource filters @@ -129,7 +133,11 @@ export function getOtelResourcesObject(scene: SceneObject, firstQueryVal?: strin const op = otelFilters[i].operator; const labelValue = otelFilters[i].value; - allFilters += `,${labelName}${op}"${labelValue}"`; + if (config.featureToggles.prometheusSpecialCharsInLabelValues) { + allFilters += `,${labelName}${op}'${labelValue}'`; + } else { + allFilters += `,${labelName}${op}"${labelValue}"`; + } const addLabelToGroupLeft = labelName !== 'job' && labelName !== 'instance'; @@ -167,8 +175,8 @@ export function limitOtelMatchTerms( let initialCharAmount = matchTerms.join(',').length; // start to add values to the regex and start quote - let jobsRegex = 'job=~"'; - let instancesRegex = 'instance=~"'; + let jobsRegex = `job=~'`; + let instancesRegex = `instance=~'`; // iterate through the jobs and instances, // count the chars as they are added, @@ -206,8 +214,8 @@ export function limitOtelMatchTerms( } } // complete the quote after values have been added - jobsRegex += '"'; - instancesRegex += '"'; + jobsRegex += `'`; + instancesRegex += `'`; return { missingOtelTargets, diff --git a/public/app/features/trails/otel/utils.test.ts b/public/app/features/trails/otel/utils.test.ts index ff54ef6f7ee..11d8657aa6b 100644 --- a/public/app/features/trails/otel/utils.test.ts +++ b/public/app/features/trails/otel/utils.test.ts @@ -164,8 +164,8 @@ describe('limitOtelMatchTerms', () => { const result = limitOtelMatchTerms(promMatchTerms, jobs, instances); expect(result.missingOtelTargets).toEqual(true); - expect(result.jobsRegex).toEqual('job=~"a"'); - expect(result.instancesRegex).toEqual('instance=~"d"'); + expect(result.jobsRegex).toEqual(`job=~'a'`); + expect(result.instancesRegex).toEqual(`instance=~'d'`); }); it('should include | char in the count', () => { @@ -189,8 +189,8 @@ describe('limitOtelMatchTerms', () => { const result = limitOtelMatchTerms(promMatchTerms, jobs, instances); expect(result.missingOtelTargets).toEqual(true); - expect(result.jobsRegex).toEqual('job=~"a|b"'); - expect(result.instancesRegex).toEqual('instance=~"d|e"'); + expect(result.jobsRegex).toEqual(`job=~'a|b'`); + expect(result.instancesRegex).toEqual(`instance=~'d|e'`); }); it('should add all OTel job and instance matches if the character count is less that 2000', () => { @@ -203,8 +203,8 @@ describe('limitOtelMatchTerms', () => { const result = limitOtelMatchTerms(promMatchTerms, jobs, instances); expect(result.missingOtelTargets).toEqual(false); - expect(result.jobsRegex).toEqual('job=~"job1|job2|job3|job4|job5"'); - expect(result.instancesRegex).toEqual('instance=~"instance1|instance2|instance3|instance4|instance5"'); + expect(result.jobsRegex).toEqual(`job=~'job1|job2|job3|job4|job5'`); + expect(result.instancesRegex).toEqual(`instance=~'instance1|instance2|instance3|instance4|instance5'`); }); }); From 2e08092a34172df04a5d8762d73b70d6b5e0d3f3 Mon Sep 17 00:00:00 2001 From: maicon Date: Wed, 18 Dec 2024 22:31:55 -0300 Subject: [PATCH 15/32] UniStore: Get Folder Handler return Root Folder (general) (#98127) Signed-off-by: Maicon Costa --- pkg/api/folder.go | 19 ++- pkg/api/folder_test.go | 149 +++++++++++++++++++++++ pkg/registry/apis/folders/conversions.go | 9 +- 3 files changed, 175 insertions(+), 2 deletions(-) diff --git a/pkg/api/folder.go b/pkg/api/folder.go index ffbe433e3fd..2c85ef59b77 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -776,7 +776,24 @@ func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) { return // error is already sent } uid := web.Params(c.Req)[":uid"] - out, err := client.Get(c.Req.Context(), uid, v1.GetOptions{}) + + var out *unstructured.Unstructured + var err error + + if uid == accesscontrol.GeneralFolderUID { + out = &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "title": folder.RootFolder.Title, + "description": folder.RootFolder.Description, + }, + }, + } + out.SetName(folder.RootFolder.UID) + } else { + out, err = client.Get(c.Req.Context(), uid, v1.GetOptions{}) + } + if err != nil { fk8s.writeError(c, err) return diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 60508e75f3f..decc34ea558 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -863,3 +863,152 @@ func TestToFolderCounts(t *testing.T) { }) } } + +// for now, test only the general folder +func TestGetFolderLegacyAndUnifiedStorage(t *testing.T) { + testuser := &user.User{ID: 99, UID: "fdxsqt7t5ryf4a", Login: "testuser"} + + legacyFolder := *folder.RootFolder + + expectedFolder := dtos.Folder{ + UID: legacyFolder.UID, + OrgID: 0, + Title: legacyFolder.Title, + URL: legacyFolder.URL, + HasACL: false, + CanSave: false, + CanEdit: true, + CanAdmin: false, + CanDelete: false, + CreatedBy: "Anonymous", + UpdatedBy: "Anonymous", + } + + mux := http.NewServeMux() + folderApiServerMock := httptest.NewServer(mux) + defer folderApiServerMock.Close() + + t.Run("happy path", func(t *testing.T) { + type testCase struct { + description string + folderUID string + legacyFolder folder.Folder + expectedFolder dtos.Folder + expectedFolderServiceError error + unifiedStorageEnabled bool + unifiedStorageMode grafanarest.DualWriterMode + expectedCode int + } + + tcs := []testCase{ + { + description: "General folder - Legacy", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageEnabled: false, + }, + { + description: "General folder - Unified storage, mode 1", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageEnabled: true, + unifiedStorageMode: grafanarest.Mode1, + }, + { + description: "General folder - Unified storage, mode 2", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageEnabled: true, + unifiedStorageMode: grafanarest.Mode2, + }, + { + description: "General folder - Unified storage, mode 3", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageEnabled: true, + unifiedStorageMode: grafanarest.Mode3, + }, + { + description: "General folder - Unified storage, mode 4", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageEnabled: true, + unifiedStorageMode: grafanarest.Mode4, + }, + } + + for _, tc := range tcs { + t.Run(tc.description, func(t *testing.T) { + setUpRBACGuardian(t) + + cfg := setting.NewCfg() + cfg.UnifiedStorage = map[string]setting.UnifiedStorageConfig{ + folderv0alpha1.RESOURCEGROUP: { + DualWriterMode: tc.unifiedStorageMode, + }, + } + + featuresArr := []any{featuremgmt.FlagNestedFolders} + if tc.unifiedStorageEnabled { + featuresArr = append(featuresArr, featuremgmt.FlagKubernetesFolders) + } + + server := SetupAPITestServer(t, func(hs *HTTPServer) { + hs.Cfg = cfg + hs.folderService = &foldertest.FakeService{ + ExpectedFolder: &tc.legacyFolder, + ExpectedError: tc.expectedFolderServiceError, + } + hs.QuotaService = quotatest.New(false, nil) + hs.SearchService = &mockSearchService{ + ExpectedResult: model.HitList{}, + } + hs.userService = &usertest.FakeUserService{ + ExpectedUser: testuser, + } + hs.Features = featuremgmt.WithFeatures( + featuresArr..., + ) + hs.clientConfigProvider = mockClientConfigProvider{ + host: folderApiServerMock.URL, + } + }) + + req := server.NewRequest(http.MethodGet, fmt.Sprintf("/api/folders/%s", tc.folderUID), nil) + req.Header.Set("Content-Type", "application/json") + webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByActionContext(context.Background(), []accesscontrol.Permission{ + {Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll}, + }), + }}) + + res, err := server.Send(req) + require.NoError(t, err) + + require.Equal(t, tc.expectedCode, res.StatusCode) + defer func() { require.NoError(t, res.Body.Close()) }() + + if tc.expectedCode == http.StatusOK { + body := dtos.Folder{} + require.NoError(t, json.NewDecoder(res.Body).Decode(&body)) + + //nolint:staticcheck + body.ID = 0 + body.Version = 0 + tc.expectedFolder.Version = 0 + require.Equal(t, tc.expectedFolder, body) + } + }) + } + }) +} diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index e76a51801f4..456771c8964 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -92,13 +92,20 @@ func UnstructuredToLegacyFolder(item unstructured.Unstructured, orgID int64) (*f createdTime = (*created).UTC() } + url := getURL(meta, title) + + // RootFolder does not have URL + if uid == folder.RootFolder.UID { + url = "" + } + f := &folder.Folder{ UID: uid, Title: title, ID: id, ParentUID: meta.GetFolder(), // #TODO add created by field if necessary - URL: getURL(meta, title), + URL: url, // #TODO get Created in format "2024-09-12T15:37:41.09466+02:00" Created: createdTime, // #TODO figure out whether we want to set "updated" and "updated by". Could replace with From f5d44ff51d9d15827e3b9d8791a4bc2ab7ab5cd8 Mon Sep 17 00:00:00 2001 From: Leonor Oliveira <9090754+leonorfmartins@users.noreply.github.com> Date: Thu, 19 Dec 2024 09:59:14 +0100 Subject: [PATCH 16/32] Folders move (#98100) --- pkg/api/folder.go | 49 +++++- pkg/registry/apis/folders/conversions.go | 16 +- pkg/registry/apis/folders/register.go | 89 +++++++++-- pkg/registry/apis/folders/register_test.go | 169 ++++++++++++++++++++- 4 files changed, 300 insertions(+), 23 deletions(-) diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 2c85ef59b77..aa15065ccbc 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -49,7 +49,6 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID)) folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { - folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder)) folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { folderPermissionRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList)) folderPermissionRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions)) @@ -65,6 +64,7 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz folderUidRoute.Delete("/", handler.deleteFolder) folderUidRoute.Get("/", handler.getFolder) folderUidRoute.Get("/counts", handler.countFolderContent) + folderUidRoute.Post("/move", handler.moveFolder) }) } else { folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder)) @@ -74,6 +74,7 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder)) folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID)) folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts)) + folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder)) }) } }) @@ -844,7 +845,51 @@ func (fk8s *folderK8sHandler) updateFolder(c *contextmodel.ReqContext) { return } - out, err := client.Update(c.Req.Context(), &obj, v1.UpdateOptions{}) + out, err := client.Update(c.Req.Context(), obj, v1.UpdateOptions{}) + if err != nil { + fk8s.writeError(c, err) + return + } + + folderDTO, err := fk8s.newToFolderDto(c, *out, c.SignedInUser.GetOrgID()) + if err != nil { + fk8s.writeError(c, err) + return + } + + c.JSON(http.StatusOK, folderDTO) +} + +func (fk8s *folderK8sHandler) moveFolder(c *contextmodel.ReqContext) { + client, ok := fk8s.getClient(c) + if !ok { + return + } + + ctx := c.Req.Context() + + cmd := folder.MoveFolderCommand{} + if err := web.Bind(c.Req, &cmd); err != nil { + c.JsonApiErr(http.StatusBadRequest, "bad request data", err) + return + } + cmd.OrgID = c.SignedInUser.GetOrgID() + cmd.UID = web.Params(c.Req)[":uid"] + cmd.SignedInUser = c.SignedInUser + + obj, err := client.Get(ctx, cmd.UID, v1.GetOptions{}) + if err != nil { + fk8s.writeError(c, err) + return + } + + obj, err = internalfolders.LegacyMoveCommandToUnstructured(obj, cmd) + if err != nil { + fk8s.writeError(c, err) + return + } + + out, err := client.Update(c.Req.Context(), obj, v1.UpdateOptions{}) if err != nil { fk8s.writeError(c, err) return diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index 456771c8964..83662596deb 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -41,9 +41,9 @@ func LegacyCreateCommandToUnstructured(cmd folder.CreateFolderCommand) (unstruct return obj, nil } -func LegacyUpdateCommandToUnstructured(cmd folder.UpdateFolderCommand) (unstructured.Unstructured, error) { +func LegacyUpdateCommandToUnstructured(cmd folder.UpdateFolderCommand) (*unstructured.Unstructured, error) { // #TODO add other fields ; do we support updating the UID/orgID? - obj := unstructured.Unstructured{ + obj := &unstructured.Unstructured{ Object: map[string]interface{}{ "spec": map[string]interface{}{ "title": cmd.NewTitle, @@ -56,8 +56,16 @@ func LegacyUpdateCommandToUnstructured(cmd folder.UpdateFolderCommand) (unstruct if cmd.NewParentUID == nil { return obj, nil } - if err := setParentUID(&obj, *cmd.NewParentUID); err != nil { - return unstructured.Unstructured{}, err + if err := setParentUID(obj, *cmd.NewParentUID); err != nil { + return &unstructured.Unstructured{}, err + } + + return obj, nil +} + +func LegacyMoveCommandToUnstructured(obj *unstructured.Unstructured, cmd folder.MoveFolderCommand) (*unstructured.Unstructured, error) { + if err := setParentUID(obj, cmd.NewParentUID); err != nil { + return &unstructured.Unstructured{}, err } return obj, nil diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index dd4a4d3c826..3f9f868c31e 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -257,7 +257,11 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes, case admission.Delete: return b.validateOnDelete(ctx, f) case admission.Update: - return nil + old := a.GetOldObject() + if old == nil { + return fmt.Errorf("old object is nil") + } + return b.validateOnUpdate(ctx, obj, old) case admission.Connect: return nil } @@ -302,22 +306,12 @@ func (b *FolderAPIBuilder) validateOnCreate(ctx context.Context, id string, obj return dashboards.ErrFolderTitleEmpty } - for i := 1; i <= folderValidationRules.maxDepth; i++ { - parent := getParent(obj) - if parent == "" { - break - } - if i == folderValidationRules.maxDepth { - return folder.ErrMaximumDepthReached - } - - parentObj, err := b.storage.Get(ctx, parent, &metav1.GetOptions{}) - if err != nil { - return err - } - obj = parentObj + _, err := b.checkFolderMaxDepth(ctx, obj) + if err != nil { + return err } - return nil + + return err } func getParent(o runtime.Object) string { @@ -327,3 +321,66 @@ func getParent(o runtime.Object) string { } return meta.GetFolder() } + +func (b *FolderAPIBuilder) checkFolderMaxDepth(ctx context.Context, obj runtime.Object) ([]string, error) { + var parents = []string{} + for i := 0; i < folderValidationRules.maxDepth; i++ { + parent := getParent(obj) + if parent == "" { + break + } + parents = append(parents, parent) + if i+1 == folderValidationRules.maxDepth { + return parents, folder.ErrMaximumDepthReached + } + + parentObj, err := b.storage.Get(ctx, parent, &metav1.GetOptions{}) + if err != nil { + return parents, err + } + obj = parentObj + } + return parents, nil +} + +func (b *FolderAPIBuilder) validateOnUpdate(ctx context.Context, obj, old runtime.Object) error { + f, ok := obj.(*v0alpha1.Folder) + if !ok { + return fmt.Errorf("obj is not v0alpha1.Folder") + } + + fOld, ok := old.(*v0alpha1.Folder) + if !ok { + return fmt.Errorf("obj is not v0alpha1.Folder") + } + var newParent = getParent(obj) + if newParent != getParent(fOld) { + // it's a move operation + return b.validateMove(ctx, obj, newParent) + } + // it's a spec update + if f.Spec.Title == "" { + return dashboards.ErrFolderTitleEmpty + } + return nil +} + +func (b *FolderAPIBuilder) validateMove(ctx context.Context, obj runtime.Object, newParent string) error { + // folder cannot be moved to a k6 folder + if newParent == accesscontrol.K6FolderUID { + return fmt.Errorf("k6 project may not be moved") + } + + //FIXME: until we have a way to represent the tree, we can only + // look at folder parents to check how deep the new folder tree will be + parents, err := b.checkFolderMaxDepth(ctx, obj) + if err != nil { + return err + } + + // if by moving a folder we exceed the max depth, return an error + if len(parents)+1 >= folderValidationRules.maxDepth { + return folder.ErrMaximumDepthReached + } + return nil +} diff --git a/pkg/registry/apis/folders/register_test.go b/pkg/registry/apis/folders/register_test.go index 1409f3e37ab..99335fc05a6 100644 --- a/pkg/registry/apis/folders/register_test.go +++ b/pkg/registry/apis/folders/register_test.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/dashboards" @@ -257,7 +258,7 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) { }, }, { - name: "should return error when creating a nested folder higher than max depth", + name: "should not allow creating a folder in a tree that is too deep", input: input{ obj: &v0alpha1.Folder{ Spec: v0alpha1.Spec{ @@ -410,3 +411,169 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) { }) } } + +func TestFolderAPIBuilder_Validate_Update(t *testing.T) { + var circularObj = &v0alpha1.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "stacks-123", + Name: "new-parent", + Annotations: map[string]string{"grafana.app/folder": "new-parent"}, + }, + } + + tests := []struct { + name string + updatedObj *v0alpha1.Folder + expected *v0alpha1.Folder + setupFn func(*mock.Mock) + wantErr bool + }{ + { + name: "should allow updating a folder spec", + updatedObj: &v0alpha1.Folder{ + Spec: v0alpha1.Spec{ + Title: "different title", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "stacks-123", + Name: "valid-name", + Annotations: map[string]string{"grafana.app/folder": "valid-parent"}, + }, + }, + expected: &v0alpha1.Folder{ + Spec: v0alpha1.Spec{ + Title: "different title", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "stacks-123", + Name: "valid-name", + Annotations: map[string]string{"grafana.app/folder": "valid-parent"}, + }, + }, + }, + { + name: "updated title should not be empty", + updatedObj: &v0alpha1.Folder{ + Spec: v0alpha1.Spec{ + Title: "", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "stacks-123", + Name: "valid-name", + Annotations: map[string]string{"grafana.app/folder": "valid-parent"}, + }, + }, + wantErr: true, + }, + { + name: "should allow moving to a valid parent", + updatedObj: &v0alpha1.Folder{ + Spec: v0alpha1.Spec{ + Title: "foo", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "stacks-123", + Name: "valid-name", + Annotations: map[string]string{"grafana.app/folder": "new-parent"}, + }, + }, + setupFn: func(m *mock.Mock) { + m.On("Get", mock.Anything, "new-parent", mock.Anything).Return( + &v0alpha1.Folder{}, + nil).Once() + }, + }, + { + name: "should not allow moving to a k6 folder", + updatedObj: &v0alpha1.Folder{ + Spec: v0alpha1.Spec{ + Title: "foo", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "stacks-123", + Name: "valid-name", + Annotations: map[string]string{"grafana.app/folder": accesscontrol.K6FolderUID}, + }, + }, + setupFn: func(m *mock.Mock) { + m.On("Get", mock.Anything, accesscontrol.K6FolderUID, mock.Anything).Return( + &v0alpha1.Folder{}, + nil).Once() + }, + wantErr: true, + }, + { + name: "should not allow moving to a folder that is too deep", + updatedObj: &v0alpha1.Folder{ + Spec: v0alpha1.Spec{ + Title: "foo", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "stacks-123", + Name: "valid-name", + Annotations: map[string]string{"grafana.app/folder": "new-parent"}, + }, + }, + setupFn: func(m *mock.Mock) { + m.On("Get", mock.Anything, "new-parent", mock.Anything).Return( + circularObj, + nil) + }, + wantErr: true, + }, + } + + s := (grafanarest.Storage)(nil) + m := &mock.Mock{} + us := storageMock{m, s} + sm := searcherMock{Mock: m} + + obj := &v0alpha1.Folder{ + Spec: v0alpha1.Spec{ + Title: "foo", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "stacks-123", + Name: "valid-name", + Annotations: map[string]string{"grafana.app/folder": "valid-parent"}, + }, + } + + for _, tt := range tests { + if tt.setupFn != nil { + tt.setupFn(m) + } + t.Run(tt.name, func(t *testing.T) { + b := &FolderAPIBuilder{ + gv: resourceInfo.GroupVersion(), + features: nil, + namespacer: func(_ int64) string { return "123" }, + folderSvc: foldertest.NewFakeService(), + storage: us, + accessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures("nestedFolders"), zanzana.NewNoopClient()), + searcher: sm, + } + + err := b.Validate(context.Background(), admission.NewAttributesRecord( + tt.updatedObj, + obj, + v0alpha1.SchemeGroupVersion.WithKind("folder"), + tt.updatedObj.Namespace, + tt.updatedObj.Name, + v0alpha1.SchemeGroupVersion.WithResource("folders"), + "", + "UPDATE", + nil, + true, + &user.SignedInUser{}, + ), + nil) + + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} From e0af98dec802ab80d023276484597db0aee51df5 Mon Sep 17 00:00:00 2001 From: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com> Date: Thu, 19 Dec 2024 09:26:42 +0000 Subject: [PATCH 17/32] POC: Unified History (#94318) --- .../components/AppChrome/AppChromeService.tsx | 29 ++- .../AppChrome/History/HistoryContainer.tsx | 80 +++++++ .../AppChrome/History/HistoryWrapper.tsx | 204 ++++++++++++++++++ .../AppChrome/TopBar/SingleTopBar.tsx | 3 + public/app/core/components/AppChrome/types.ts | 25 +++ .../app/core/components/Breadcrumbs/utils.ts | 11 +- .../app/features/trails/DataTrailsHistory.tsx | 30 ++- public/app/types/events.ts | 5 + public/locales/en-US/grafana.json | 10 + public/locales/pseudo-LOCALE/grafana.json | 10 + 10 files changed, 395 insertions(+), 12 deletions(-) create mode 100644 public/app/core/components/AppChrome/History/HistoryContainer.tsx create mode 100644 public/app/core/components/AppChrome/History/HistoryWrapper.tsx diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index 15a952429f1..55ec8cf7490 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -10,9 +10,10 @@ import { isShallowEqual } from 'app/core/utils/isShallowEqual'; import { KioskMode } from 'app/types'; import { RouteDescriptor } from '../../navigation/types'; +import { buildBreadcrumbs } from '../Breadcrumbs/utils'; import { ReturnToPreviousProps } from './ReturnToPrevious/ReturnToPrevious'; -import { TOP_BAR_LEVEL_HEIGHT } from './types'; +import { HistoryEntry, TOP_BAR_LEVEL_HEIGHT } from './types'; export interface AppChromeState { chromeless?: boolean; @@ -31,6 +32,7 @@ export interface AppChromeState { export const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked'; export const DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY = 'grafana.navigation.open'; +export const HISTORY_LOCAL_STORAGE_KEY = 'grafana.navigation.history'; export class AppChromeService { searchBarStorageKey = 'SearchBar_Hidden'; @@ -99,6 +101,8 @@ export class AppChromeService { newState.chromeless = newState.kioskMode === KioskMode.Full || this.currentRoute?.chromeless; if (!this.ignoreStateUpdate(newState, current)) { + config.featureToggles.unifiedHistory && + store.setObject(HISTORY_LOCAL_STORAGE_KEY, this.getUpdatedHistory(newState)); this.state.next(newState); } } @@ -127,6 +131,29 @@ export class AppChromeService { window.sessionStorage.removeItem('returnToPrevious'); }; + private getUpdatedHistory(newState: AppChromeState): HistoryEntry[] { + const breadcrumbs = buildBreadcrumbs(newState.sectionNav.node, newState.pageNav, { text: 'Home', url: '/' }, true); + const newPageNav = newState.pageNav || newState.sectionNav.node; + + let entries = store.getObject(HISTORY_LOCAL_STORAGE_KEY, []); + const clickedHistory = store.getObject('CLICKING_HISTORY'); + if (clickedHistory) { + store.setObject('CLICKING_HISTORY', false); + return entries; + } + if (!newPageNav) { + return entries; + } + + let lastEntry = entries[0]; + if (!lastEntry || lastEntry.name !== newPageNav.text) { + lastEntry = { name: newPageNav.text, views: [], breadcrumbs, time: Date.now(), url: window.location.href }; + } + if (lastEntry !== entries[0]) { + entries = [lastEntry, ...entries]; + } + return entries; + } private ignoreStateUpdate(newState: AppChromeState, current: AppChromeState) { if (isShallowEqual(newState, current)) { return true; diff --git a/public/app/core/components/AppChrome/History/HistoryContainer.tsx b/public/app/core/components/AppChrome/History/HistoryContainer.tsx new file mode 100644 index 00000000000..5d8482a3697 --- /dev/null +++ b/public/app/core/components/AppChrome/History/HistoryContainer.tsx @@ -0,0 +1,80 @@ +import { css } from '@emotion/css'; +import { useEffect } from 'react'; +import { useToggle } from 'react-use'; + +import { GrafanaTheme2, store } from '@grafana/data'; +import { Drawer, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { appEvents } from 'app/core/app_events'; +import { t } from 'app/core/internationalization'; +import { RecordHistoryEntryEvent } from 'app/types/events'; + +import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService'; +import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; +import { HistoryEntry } from '../types'; + +import { HistoryWrapper } from './HistoryWrapper'; + +export function HistoryContainer() { + const [showHistoryDrawer, onToggleShowHistoryDrawer] = useToggle(false); + const styles = useStyles2(getStyles); + + useEffect(() => { + const sub = appEvents.subscribe(RecordHistoryEntryEvent, (ev) => { + const clickedHistory = store.getObject('CLICKING_HISTORY'); + if (clickedHistory) { + store.setObject('CLICKING_HISTORY', false); + return; + } + const history = store.getObject(HISTORY_LOCAL_STORAGE_KEY, []); + let lastEntry = history[0]; + const newUrl = ev.payload.url; + const lastUrl = lastEntry.views[0]?.url; + if (lastUrl !== newUrl) { + lastEntry.views = [ + { + name: ev.payload.name, + description: ev.payload.description, + url: newUrl, + time: Date.now(), + }, + ...lastEntry.views, + ]; + store.setObject(HISTORY_LOCAL_STORAGE_KEY, [...history]); + } + return () => { + sub.unsubscribe(); + }; + }); + }, []); + + return ( + <> + + + {showHistoryDrawer && ( + + onToggleShowHistoryDrawer(false)} /> + + )} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + separator: css({ + [theme.breakpoints.down('sm')]: { + display: 'none', + }, + }), + }; +}; diff --git a/public/app/core/components/AppChrome/History/HistoryWrapper.tsx b/public/app/core/components/AppChrome/History/HistoryWrapper.tsx new file mode 100644 index 00000000000..687f631fd8c --- /dev/null +++ b/public/app/core/components/AppChrome/History/HistoryWrapper.tsx @@ -0,0 +1,204 @@ +import { css } from '@emotion/css'; +import moment from 'moment'; +import { useState } from 'react'; + +import { FieldType, GrafanaTheme2, store } from '@grafana/data'; +import { Button, Card, IconButton, Space, Stack, Text, useStyles2, Box, Sparkline, useTheme2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService'; +import { HistoryEntry } from '../types'; + +export function HistoryWrapper({ onClose }: { onClose: () => void }) { + const history = store.getObject(HISTORY_LOCAL_STORAGE_KEY, []).filter((entry) => { + return moment(entry.time).isAfter(moment().subtract(2, 'day').startOf('day')); + }); + const [numItemsToShow, setNumItemsToShow] = useState(5); + + const selectedTime = history.find((entry) => { + return entry.url === window.location.href || entry.views.some((view) => view.url === window.location.href); + })?.time; + + const hist = history.slice(0, numItemsToShow).reduce((acc: { [key: string]: HistoryEntry[] }, entry) => { + const date = moment(entry.time); + let key = ''; + if (date.isSame(moment(), 'day')) { + key = t('nav.history-wrapper.today', 'Today'); + } else if (date.isSame(moment().subtract(1, 'day'), 'day')) { + key = t('nav.history-wrapper.yesterday', 'Yesterday'); + } else { + key = date.format('YYYY-MM-DD'); + } + acc[key] = [...(acc[key] || []), entry]; + return acc; + }, {}); + + return ( + + + {Object.keys(hist).map((entries, date) => { + return ( + + + {entries} + + {hist[entries].map((entry, index) => { + return ( + onClose()} + /> + ); + })} + + ); + })} + + {history.length > numItemsToShow && ( + + )} + + ); +} +interface ItemProps { + entry: HistoryEntry; + isSelected: boolean; + onClick: () => void; +} + +function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) { + const styles = useStyles2(getStyles); + const theme = useTheme2(); + const [isExpanded, setIsExpanded] = useState(isSelected && entry.views.length > 0); + const { breadcrumbs, views, time, url, sparklineData } = entry; + const expandedLabel = isExpanded + ? t('nav.history-wrapper.collapse', 'Collapse') + : t('nav.history-wrapper.expand', 'Expand'); + + const selectedViewTime = + isSelected && + entry.views.find((entry) => { + return entry.url === window.location.href; + })?.time; + + return ( + + + {views.length > 0 ? ( + setIsExpanded(!isExpanded)} + aria-label={expandedLabel} + className={styles.iconButton} + /> + ) : ( + + )} + + { + store.setObject('CLICKING_HISTORY', true); + onClick(); + }} + href={url} + isCompact={true} + className={isSelected ? undefined : styles.card} + > + +
+ {breadcrumbs.map((breadcrumb, index) => ( + + {breadcrumb.text} {index !== breadcrumbs.length - 1 ? '> ' : ''} + + ))} +
+ {moment(time).format('h:mm A')} + {sparklineData && ( + + )} +
+
+
+ {isExpanded && ( +
+ {views.map((view, index) => { + return ( + { + store.setObject('CLICKING_HISTORY', true); + onClick(); + }} + isCompact={true} + className={view.time === selectedViewTime ? undefined : styles.card} + > + + {view.name} + {view.description && ( + + {view.description} + + )} + + + ); + })} +
+ )} +
+ ); +} +const getStyles = (theme: GrafanaTheme2) => { + return { + card: css({ + background: 'none', + }), + iconButton: css({ + margin: 0, + }), + expanded: css({ + display: 'flex', + flexDirection: 'column', + marginLeft: theme.spacing(5), + gap: theme.spacing(1), + position: 'relative', + '&:before': { + content: '""', + position: 'absolute', + left: theme.spacing(-2), + top: 0, + height: '100%', + width: '1px', + background: theme.colors.border.weak, + }, + }), + }; +}; diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx index 8b9b61cf41e..2bee868f11f 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -14,6 +14,7 @@ import { useSelector } from 'app/types'; import { Branding } from '../../Branding/Branding'; import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs'; import { buildBreadcrumbs } from '../../Breadcrumbs/utils'; +import { HistoryContainer } from '../History/HistoryContainer'; import { enrichHelpItem } from '../MegaMenu/utils'; import { NewsContainer } from '../News/NewsContainer'; import { QuickAdd } from '../QuickAdd/QuickAdd'; @@ -49,6 +50,7 @@ export const SingleTopBar = memo(function SingleTopBar({ const profileNode = navIndex['profile']; const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID]; const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav); + const unifiedHistoryEnabled = config.featureToggles.unifiedHistory; return (
@@ -72,6 +74,7 @@ export const SingleTopBar = memo(function SingleTopBar({ + {unifiedHistoryEnabled && } {enrichedHelpNode && ( } placement="bottom-end"> diff --git a/public/app/core/components/AppChrome/types.ts b/public/app/core/components/AppChrome/types.ts index d307c26d351..1882e24d9a8 100644 --- a/public/app/core/components/AppChrome/types.ts +++ b/public/app/core/components/AppChrome/types.ts @@ -5,3 +5,28 @@ export interface ToolbarUpdateProps { pageNav?: NavModelItem; actions?: React.ReactNode; } + +export interface HistoryEntryView { + name: string; + description: string; + url: string; + time: number; +} + +export interface HistoryEntrySparkline { + values: number[]; + range: { + min: number; + max: number; + delta: number; + }; +} + +export interface HistoryEntry { + name: string; + time: number; + breadcrumbs: NavModelItem[]; + url: string; + views: HistoryEntryView[]; + sparklineData?: HistoryEntrySparkline; +} diff --git a/public/app/core/components/Breadcrumbs/utils.ts b/public/app/core/components/Breadcrumbs/utils.ts index 2719ec92b48..9df7bc79221 100644 --- a/public/app/core/components/Breadcrumbs/utils.ts +++ b/public/app/core/components/Breadcrumbs/utils.ts @@ -2,7 +2,12 @@ import { NavModelItem } from '@grafana/data'; import { Breadcrumb } from './types'; -export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelItem, homeNav?: NavModelItem) { +export function buildBreadcrumbs( + sectionNav: NavModelItem, + pageNav?: NavModelItem, + homeNav?: NavModelItem, + skipHome?: boolean +) { const crumbs: Breadcrumb[] = []; let foundHome = false; let lastPath: string | undefined = undefined; @@ -22,7 +27,9 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte // Check if we found home/root if if so return early if (homeNav && urlToMatch === homeNav.url) { - crumbs.unshift({ text: homeNav.text, href: node.url ?? '' }); + if (!skipHome) { + crumbs.unshift({ text: homeNav.text, href: node.url ?? '' }); + } foundHome = true; return; } diff --git a/public/app/features/trails/DataTrailsHistory.tsx b/public/app/features/trails/DataTrailsHistory.tsx index 26b3d6b4e05..aa66384eb4f 100644 --- a/public/app/features/trails/DataTrailsHistory.tsx +++ b/public/app/features/trails/DataTrailsHistory.tsx @@ -3,6 +3,7 @@ import { useMemo } from 'react'; import { getTimeZoneInfo, GrafanaTheme2, InternalTimeZones, TIME_FORMAT } from '@grafana/data'; import { convertRawToRange } from '@grafana/data/src/datetime/rangeutil'; +import { config } from '@grafana/runtime'; import { SceneComponentProps, SceneObjectBase, @@ -15,6 +16,8 @@ import { SceneVariableValueChangedEvent, } from '@grafana/scenes'; import { Stack, Tooltip, useStyles2 } from '@grafana/ui'; +import { appEvents } from 'app/core/app_events'; +import { RecordHistoryEntryEvent } from 'app/types/events'; import { DataTrail, DataTrailState, getTopSceneFor } from './DataTrail'; import { SerializedTrailHistory } from './TrailStore/TrailStore'; @@ -148,15 +151,24 @@ export class DataTrailHistory extends SceneObjectBase { return; } - this.addTrailStep( - trail, - 'time', - parseTimeTooltip({ - from: newState.from, - to: newState.to, - timeZone: newState.timeZone, - }) - ); + const tooltip = parseTimeTooltip({ + from: newState.from, + to: newState.to, + timeZone: newState.timeZone, + }); + + this.addTrailStep(trail, 'time', tooltip); + + if (config.featureToggles.unifiedHistory) { + appEvents.publish( + new RecordHistoryEntryEvent({ + name: 'Time range changed', + description: tooltip, + url: window.location.href, + time: Date.now(), + }) + ); + } } } }); diff --git a/public/app/types/events.ts b/public/app/types/events.ts index 0b03e21b20d..eb45e260f43 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -1,5 +1,6 @@ import { AnnotationQuery, BusEventBase, BusEventWithPayload, eventFactory } from '@grafana/data'; import { IconName, ButtonVariant } from '@grafana/ui'; +import { HistoryEntryView } from 'app/core/components/AppChrome/types'; /** * Event Payloads @@ -209,3 +210,7 @@ export class PanelEditEnteredEvent extends BusEventWithPayload { export class PanelEditExitedEvent extends BusEventWithPayload { static type = 'panel-edit-finished'; } + +export class RecordHistoryEntryEvent extends BusEventWithPayload { + static type = 'record-history-entry'; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index db81976f539..b189cfd388b 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2176,6 +2176,16 @@ "help/documentation": "Documentation", "help/keyboard-shortcuts": "Keyboard shortcuts", "help/support": "Support", + "history-container": { + "drawer-tittle": "History" + }, + "history-wrapper": { + "collapse": "Collapse", + "expand": "Expand", + "show-more": "Show more", + "today": "Today", + "yesterday": "Yesterday" + }, "home": { "title": "Home" }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index cff6f710dd9..49fc10fb067 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2176,6 +2176,16 @@ "help/documentation": "Đőčūmęʼnŧäŧįőʼn", "help/keyboard-shortcuts": "Ķęyþőäřđ şĥőřŧčūŧş", "help/support": "Ŝūppőřŧ", + "history-container": { + "drawer-tittle": "Ħįşŧőřy" + }, + "history-wrapper": { + "collapse": "Cőľľäpşę", + "expand": "Ēχpäʼnđ", + "show-more": "Ŝĥőŵ mőřę", + "today": "Ŧőđäy", + "yesterday": "Ÿęşŧęřđäy" + }, "home": { "title": "Ħőmę" }, From c901b76a8a14802806fcea7c87206b74400f1fd1 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Thu, 19 Dec 2024 10:53:33 +0100 Subject: [PATCH 18/32] Transformations: GroupToMatrix add 0 as special value (#97642) * Add support for 0 empty value * Remove only * Add zero option --- .../transformers/groupingToMatrix.test.ts | 15 +++++++++++---- .../transformers/groupingToMatrix.ts | 2 ++ .../grafana-data/src/types/transformations.ts | 1 + .../editors/GroupingToMatrixTransformerEditor.tsx | 1 + 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts index 057c41c2fec..bf358b539fd 100644 --- a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts +++ b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts @@ -105,11 +105,18 @@ describe('Grouping to Matrix', () => { }); }); - it('generates Matrix with empty entries', async () => { + it.each([ + [undefined, ''], + [SpecialValue.Null, null], + [SpecialValue.False, false], + [SpecialValue.True, true], + [SpecialValue.Empty, ''], + [SpecialValue.Zero, 0], + ])('generates Matrix with empty entries', async (emptyValue, expectedValue) => { const cfg: DataTransformerConfig = { id: DataTransformerID.groupingToMatrix, options: { - emptyValue: SpecialValue.Null, + emptyValue: emptyValue, }, }; @@ -133,13 +140,13 @@ describe('Grouping to Matrix', () => { { name: '1000', type: FieldType.number, - values: [1, null], + values: [1, expectedValue], config: {}, }, { name: '1001', type: FieldType.number, - values: [null, 2], + values: [expectedValue, 2], config: {}, }, ]; diff --git a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts index 62b3d4e8a74..59c6d015df5 100644 --- a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts +++ b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts @@ -183,6 +183,8 @@ function getSpecialValue(specialValue: SpecialValue) { return true; case SpecialValue.Null: return null; + case SpecialValue.Zero: + return 0; case SpecialValue.Empty: default: return ''; diff --git a/packages/grafana-data/src/types/transformations.ts b/packages/grafana-data/src/types/transformations.ts index 37501f7d982..f002310e6e1 100644 --- a/packages/grafana-data/src/types/transformations.ts +++ b/packages/grafana-data/src/types/transformations.ts @@ -115,4 +115,5 @@ export enum SpecialValue { False = 'false', Null = 'null', Empty = 'empty', + Zero = 'zero', } diff --git a/public/app/features/transformers/editors/GroupingToMatrixTransformerEditor.tsx b/public/app/features/transformers/editors/GroupingToMatrixTransformerEditor.tsx index a129ddd1ef4..878b9b879d8 100644 --- a/public/app/features/transformers/editors/GroupingToMatrixTransformerEditor.tsx +++ b/public/app/features/transformers/editors/GroupingToMatrixTransformerEditor.tsx @@ -62,6 +62,7 @@ export const GroupingToMatrixTransformerEditor = ({ { label: 'Null', value: SpecialValue.Null, description: 'Null value' }, { label: 'True', value: SpecialValue.True, description: 'Boolean true value' }, { label: 'False', value: SpecialValue.False, description: 'Boolean false value' }, + { label: 'Zero', value: SpecialValue.Zero, description: 'Number 0 value' }, { label: 'Empty', value: SpecialValue.Empty, description: 'Empty string' }, ]; From cbddc41ad5a4dc648514d7f7e7eec734551eb1e2 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 19 Dec 2024 13:04:09 +0300 Subject: [PATCH 19/32] Chore: Avoid logging error for UID mismatch (#98151) --- pkg/storage/unified/apistore/prepare.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/storage/unified/apistore/prepare.go b/pkg/storage/unified/apistore/prepare.go index b6dc7f15a85..8bad6bfbe0a 100644 --- a/pkg/storage/unified/apistore/prepare.go +++ b/pkg/storage/unified/apistore/prepare.go @@ -100,9 +100,11 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti if previous.GetUID() == "" { klog.Errorf("object is missing UID: %s, %s", obj.GetGroupVersionKind().String(), obj.GetName()) } else if obj.GetUID() != previous.GetUID() { - if obj.GetUID() != "" { - klog.Errorf("object UID mismatch: %s, was:%s, now: %s", obj.GetGroupVersionKind().String(), previous.GetName(), obj.GetUID()) - } + // Eventually this should be a real error or logged + // However the dashboard dual write behavior hits this every time, so we will ignore it + // if obj.GetUID() != "" { + // klog.Errorf("object UID mismatch: %s, was:%s, now: %s", obj.GetGroupVersionKind().String(), previous.GetName(), obj.GetUID()) + // } obj.SetUID(previous.GetUID()) } From 2303694293b6a4e2d44c057a2ce9176864e918d9 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 10:38:55 +0000 Subject: [PATCH 20/32] I18n: Download translations from Crowdin (#98224) --- public/locales/de-DE/grafana.json | 36 ++++++++++++++++++++++++++++- public/locales/es-ES/grafana.json | 36 ++++++++++++++++++++++++++++- public/locales/fr-FR/grafana.json | 36 ++++++++++++++++++++++++++++- public/locales/pt-BR/grafana.json | 36 ++++++++++++++++++++++++++++- public/locales/zh-Hans/grafana.json | 36 ++++++++++++++++++++++++++++- 5 files changed, 175 insertions(+), 5 deletions(-) diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 8efb1b74206..61c78903a3d 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -1715,7 +1715,10 @@ }, "share-panel": { "config-description": "", - "render-image": "" + "download-image": "", + "render-image": "", + "render-image-error": "", + "render-image-error-description": "" } }, "login": { @@ -2173,6 +2176,16 @@ "help/documentation": "Dokumentation", "help/keyboard-shortcuts": "Tastaturbefehle", "help/support": "Support", + "history-container": { + "drawer-tittle": "" + }, + "history-wrapper": { + "collapse": "", + "expand": "", + "show-more": "", + "today": "", + "yesterday": "" + }, "home": { "title": "Home" }, @@ -3033,6 +3046,27 @@ "create-button": "" } }, + "share-panel-image": { + "preview": { + "title": "" + }, + "settings": { + "height-label": "", + "height-min": "", + "height-placeholder": "", + "height-required": "", + "max-warning": "", + "scale-factor-label": "", + "scale-factor-min": "", + "scale-factor-placeholder": "", + "scale-factor-required": "", + "title": "", + "width-label": "", + "width-min": "", + "width-placeholder": "", + "width-required": "" + } + }, "share-playlist": { "checkbox-description": "Fensterhöhen werden an die Bildschirmgröße angepasst", "checkbox-label": "Autofit", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 94fc44c782c..f73ea2179d2 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -1715,7 +1715,10 @@ }, "share-panel": { "config-description": "", - "render-image": "" + "download-image": "", + "render-image": "", + "render-image-error": "", + "render-image-error-description": "" } }, "login": { @@ -2173,6 +2176,16 @@ "help/documentation": "Documentación", "help/keyboard-shortcuts": "Atajos de teclado", "help/support": "Asistencia", + "history-container": { + "drawer-tittle": "" + }, + "history-wrapper": { + "collapse": "", + "expand": "", + "show-more": "", + "today": "", + "yesterday": "" + }, "home": { "title": "Inicio" }, @@ -3033,6 +3046,27 @@ "create-button": "" } }, + "share-panel-image": { + "preview": { + "title": "" + }, + "settings": { + "height-label": "", + "height-min": "", + "height-placeholder": "", + "height-required": "", + "max-warning": "", + "scale-factor-label": "", + "scale-factor-min": "", + "scale-factor-placeholder": "", + "scale-factor-required": "", + "title": "", + "width-label": "", + "width-min": "", + "width-placeholder": "", + "width-required": "" + } + }, "share-playlist": { "checkbox-description": "La altura del panel se ajustará para adecuarse al tamaño de la pantalla", "checkbox-label": "Ajuste automático", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index a09f6fb3941..54f37b42e79 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -1715,7 +1715,10 @@ }, "share-panel": { "config-description": "", - "render-image": "" + "download-image": "", + "render-image": "", + "render-image-error": "", + "render-image-error-description": "" } }, "login": { @@ -2173,6 +2176,16 @@ "help/documentation": "Documentation", "help/keyboard-shortcuts": "Raccourcis clavier", "help/support": "Assistance", + "history-container": { + "drawer-tittle": "" + }, + "history-wrapper": { + "collapse": "", + "expand": "", + "show-more": "", + "today": "", + "yesterday": "" + }, "home": { "title": "Accueil" }, @@ -3033,6 +3046,27 @@ "create-button": "" } }, + "share-panel-image": { + "preview": { + "title": "" + }, + "settings": { + "height-label": "", + "height-min": "", + "height-placeholder": "", + "height-required": "", + "max-warning": "", + "scale-factor-label": "", + "scale-factor-min": "", + "scale-factor-placeholder": "", + "scale-factor-required": "", + "title": "", + "width-label": "", + "width-min": "", + "width-placeholder": "", + "width-required": "" + } + }, "share-playlist": { "checkbox-description": "Les hauteurs du panneau seront ajustées à la taille de l'écran", "checkbox-label": "Ajustement automatique", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 6c5dc71854d..61ece0bfb6d 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -1715,7 +1715,10 @@ }, "share-panel": { "config-description": "", - "render-image": "" + "download-image": "", + "render-image": "", + "render-image-error": "", + "render-image-error-description": "" } }, "login": { @@ -2173,6 +2176,16 @@ "help/documentation": "Documentação", "help/keyboard-shortcuts": "Atalhos do teclado", "help/support": "Suporte", + "history-container": { + "drawer-tittle": "" + }, + "history-wrapper": { + "collapse": "", + "expand": "", + "show-more": "", + "today": "", + "yesterday": "" + }, "home": { "title": "Página inicial" }, @@ -3033,6 +3046,27 @@ "create-button": "" } }, + "share-panel-image": { + "preview": { + "title": "" + }, + "settings": { + "height-label": "", + "height-min": "", + "height-placeholder": "", + "height-required": "", + "max-warning": "", + "scale-factor-label": "", + "scale-factor-min": "", + "scale-factor-placeholder": "", + "scale-factor-required": "", + "title": "", + "width-label": "", + "width-min": "", + "width-placeholder": "", + "width-required": "" + } + }, "share-playlist": { "checkbox-description": "As alturas do painel serão ajustadas para caber no tamanho da tela", "checkbox-label": "Ajuste automático", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index c9f1313f029..13aaa0c0500 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -1705,7 +1705,10 @@ }, "share-panel": { "config-description": "", - "render-image": "" + "download-image": "", + "render-image": "", + "render-image-error": "", + "render-image-error-description": "" } }, "login": { @@ -2163,6 +2166,16 @@ "help/documentation": "文档", "help/keyboard-shortcuts": "快捷键", "help/support": "支持", + "history-container": { + "drawer-tittle": "" + }, + "history-wrapper": { + "collapse": "", + "expand": "", + "show-more": "", + "today": "", + "yesterday": "" + }, "home": { "title": "首页" }, @@ -3019,6 +3032,27 @@ "create-button": "" } }, + "share-panel-image": { + "preview": { + "title": "" + }, + "settings": { + "height-label": "", + "height-min": "", + "height-placeholder": "", + "height-required": "", + "max-warning": "", + "scale-factor-label": "", + "scale-factor-min": "", + "scale-factor-placeholder": "", + "scale-factor-required": "", + "title": "", + "width-label": "", + "width-min": "", + "width-placeholder": "", + "width-required": "" + } + }, "share-playlist": { "checkbox-description": "将调整面板高度以适应屏幕大小", "checkbox-label": "自动调整", From 148289258f1c2c8cff318856155bad9102db96e5 Mon Sep 17 00:00:00 2001 From: Younjin Song Date: Thu, 19 Dec 2024 02:54:51 -0800 Subject: [PATCH 21/32] Azure: Unify credentials in frontend for Prometheus (#96568) init --- .../configuration/AzureAuthSettings.tsx | 2 +- .../configuration/AzureCredentials.ts | 46 ------ .../configuration/AzureCredentialsConfig.ts | 139 ++++-------------- .../AzureCredentialsForm.test.tsx | 12 -- .../configuration/AzureCredentialsForm.tsx | 99 +------------ .../configuration/ConfigEditorPackage.tsx | 9 +- .../DataSourceHttpSettingsOverhaulPackage.tsx | 13 +- 7 files changed, 40 insertions(+), 280 deletions(-) delete mode 100644 public/app/plugins/datasource/prometheus/configuration/AzureCredentials.ts diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx b/public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx index f0fd6570dc9..6152b70cb00 100644 --- a/public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx @@ -2,11 +2,11 @@ import { cx } from '@emotion/css'; import { FormEvent, useMemo, useState } from 'react'; import { useEffectOnce } from 'react-use'; +import { AzureCredentials } from '@grafana/azure-sdk'; import { config } from '@grafana/runtime'; import { InlineField, InlineFieldRow, InlineSwitch, Input } from '@grafana/ui'; import { HttpSettingsBaseProps } from '@grafana/ui/src/components/DataSourceSettings/types'; -import { AzureCredentials } from './AzureCredentials'; import { getAzureCloudOptions, getCredentials, updateCredentials } from './AzureCredentialsConfig'; import { AzureCredentialsForm } from './AzureCredentialsForm'; diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureCredentials.ts b/public/app/plugins/datasource/prometheus/configuration/AzureCredentials.ts deleted file mode 100644 index d3d5e32130a..00000000000 --- a/public/app/plugins/datasource/prometheus/configuration/AzureCredentials.ts +++ /dev/null @@ -1,46 +0,0 @@ -export enum AzureCloud { - Public = 'AzureCloud', - China = 'AzureChinaCloud', - USGovernment = 'AzureUSGovernment', - None = '', -} - -export type AzureAuthType = 'msi' | 'clientsecret' | 'workloadidentity'; - -export type ConcealedSecret = symbol; - -interface AzureCredentialsBase { - authType: AzureAuthType; - defaultSubscriptionId?: string; -} - -export interface AzureManagedIdentityCredentials extends AzureCredentialsBase { - authType: 'msi'; -} - -export interface AzureWorkloadIdentityCredentials extends AzureCredentialsBase { - authType: 'workloadidentity'; -} - -export interface AzureClientSecretCredentials extends AzureCredentialsBase { - authType: 'clientsecret'; - azureCloud?: string; - tenantId?: string; - clientId?: string; - clientSecret?: string | ConcealedSecret; -} - -export type AzureCredentials = - | AzureManagedIdentityCredentials - | AzureClientSecretCredentials - | AzureWorkloadIdentityCredentials; - -export function isCredentialsComplete(credentials: AzureCredentials): boolean { - switch (credentials.authType) { - case 'msi': - case 'workloadidentity': - return true; - case 'clientsecret': - return !!(credentials.azureCloud && credentials.tenantId && credentials.clientId && credentials.clientSecret); - } -} diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsConfig.ts b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsConfig.ts index ca76ccd4e80..e1b88890215 100644 --- a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsConfig.ts +++ b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsConfig.ts @@ -1,29 +1,17 @@ -import { getAzureClouds } from '@grafana/azure-sdk'; +import { + AzureCredentials, + AzureDataSourceJsonData, + AzureDataSourceSecureJsonData, + AzureDataSourceSettings, + getAzureClouds, + getDatasourceCredentials, + getDefaultAzureCloud, + updateDatasourceCredentials, +} from '@grafana/azure-sdk'; import { DataSourceSettings, SelectableValue } from '@grafana/data'; +import { PromOptions } from '@grafana/prometheus'; import { config } from '@grafana/runtime'; -import { AzureCloud, AzureCredentials, ConcealedSecret } from './AzureCredentials'; - -const concealed: ConcealedSecret = Symbol('Concealed client secret'); - -function getDefaultAzureCloud(): string { - return config.azure.cloud || AzureCloud.Public; -} - -function getSecret(options: DataSourceSettings): undefined | string | ConcealedSecret { - if (options.secureJsonFields.azureClientSecret) { - // The secret is concealed on server - return concealed; - } else { - const secret = options.secureJsonData?.azureClientSecret; - return typeof secret === 'string' && secret.length > 0 ? secret : undefined; - } -} - -export function hasCredentials(options: DataSourceSettings): boolean { - return !!options.jsonData.azureCredentials; -} - export function getAzureCloudOptions(): Array> { const cloudInfo = getAzureClouds(); @@ -41,101 +29,25 @@ export function getDefaultCredentials(): AzureCredentials { } } -export function getCredentials(options: DataSourceSettings): AzureCredentials { - const credentials = options.jsonData.azureCredentials as AzureCredentials | undefined; +export function getCredentials(options: AzureDataSourceSettings): AzureCredentials { + const credentials = getDatasourceCredentials(options); + if (credentials) { + return credentials; + } // If no credentials saved, then return empty credentials // of type based on whether the managed identity enabled - if (!credentials) { - return getDefaultCredentials(); - } - - switch (credentials.authType) { - case 'msi': - case 'workloadidentity': - if ( - (credentials.authType === 'msi' && config.azure.managedIdentityEnabled) || - (credentials.authType === 'workloadidentity' && config.azure.workloadIdentityEnabled) - ) { - return { - authType: credentials.authType, - }; - } else { - // If authentication type is managed identity or workload identity but either method is disabled in Grafana config, - // then we should fallback to an empty app registration (client secret) configuration - return { - authType: 'clientsecret', - azureCloud: getDefaultAzureCloud(), - }; - } - case 'clientsecret': - return { - authType: 'clientsecret', - azureCloud: credentials.azureCloud || getDefaultAzureCloud(), - tenantId: credentials.tenantId, - clientId: credentials.clientId, - clientSecret: getSecret(options), - }; - } + return getDefaultCredentials(); } export function updateCredentials( - options: DataSourceSettings, + options: AzurePromDataSourceSettings, credentials: AzureCredentials -): DataSourceSettings { - switch (credentials.authType) { - case 'msi': - case 'workloadidentity': - if (credentials.authType === 'msi' && !config.azure.managedIdentityEnabled) { - throw new Error('Managed Identity authentication is not enabled in Grafana config.'); - } - if (credentials.authType === 'workloadidentity' && !config.azure.workloadIdentityEnabled) { - throw new Error('Workload Identity authentication is not enabled in Grafana config.'); - } - - options = { - ...options, - jsonData: { - ...options.jsonData, - azureAuthType: credentials.authType, - azureCredentials: { - authType: credentials.authType, - }, - }, - }; - - return options; - - case 'clientsecret': - options = { - ...options, - jsonData: { - ...options.jsonData, - azureCredentials: { - authType: 'clientsecret', - azureCloud: credentials.azureCloud || getDefaultAzureCloud(), - tenantId: credentials.tenantId, - clientId: credentials.clientId, - }, - }, - secureJsonData: { - ...options.secureJsonData, - azureClientSecret: - typeof credentials.clientSecret === 'string' && credentials.clientSecret.length > 0 - ? credentials.clientSecret - : undefined, - }, - secureJsonFields: { - ...options.secureJsonFields, - azureClientSecret: typeof credentials.clientSecret === 'symbol', - }, - }; - - return options; - } +): AzurePromDataSourceSettings { + return updateDatasourceCredentials(options, credentials); } -export function setDefaultCredentials(options: DataSourceSettings): Partial> { +export function setDefaultCredentials(options: AzurePromDataSourceSettings): Partial { return { jsonData: { ...options.jsonData, @@ -144,13 +56,18 @@ export function setDefaultCredentials(options: DataSourceSettings): Pa }; } -export function resetCredentials(options: DataSourceSettings): Partial> { +export function resetCredentials(options: AzurePromDataSourceSettings): Partial { return { jsonData: { ...options.jsonData, - azureAuth: undefined, azureCredentials: undefined, azureEndpointResourceId: undefined, }, }; } + +export interface AzurePromDataSourceOptions extends PromOptions, AzureDataSourceJsonData { + azureEndpointResourceId?: string; +} + +export type AzurePromDataSourceSettings = DataSourceSettings; diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.test.tsx b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.test.tsx index 30ead47840b..d323dc8fb8f 100644 --- a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.test.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.test.tsx @@ -12,7 +12,6 @@ const setup = (propsFunc?: (props: Props) => Props) => { tenantId: 'e7f3f661-a933-3h3f-0294-31c4f962ec48', clientId: '34509fad-c0r9-45df-9e25-f1ee34af6900', clientSecret: undefined, - defaultSubscriptionId: '44987801-6nn6-49he-9b2d-9106972f9789', }, azureCloudOptions: [ { value: 'azuremonitor', label: 'Azure' }, @@ -45,15 +44,4 @@ describe('AzureCredentialsForm', () => { })); expect(await screen.findByLabelText('Client Secret')).toBeDisabled(); }); - - it('should enable azure monitor load subscriptions button when all required fields are defined', async () => { - setup((props) => ({ - ...props, - credentials: { - ...props.credentials, - clientSecret: 'e7f3f661-a933-4b3f-8176-51c4f982ec48', - }, - })); - expect(await screen.findByRole('button', { name: 'Load Subscriptions' })).not.toBeDisabled(); - }); }); diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx index 9bd461a8311..cecba3f9bc8 100644 --- a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx @@ -1,12 +1,11 @@ import { cx } from '@emotion/css'; -import { ChangeEvent, useEffect, useMemo, useReducer, useState } from 'react'; +import { ChangeEvent, useMemo } from 'react'; +import { AzureAuthType, AzureCredentials } from '@grafana/azure-sdk'; import { SelectableValue } from '@grafana/data'; import { config } from '@grafana/runtime'; import { InlineFormLabel, Button, Select, Input } from '@grafana/ui'; -import { AzureAuthType, AzureCredentials, isCredentialsComplete } from './AzureCredentials'; - export interface Props { managedIdentityEnabled: boolean; workloadIdentityEnabled: boolean; @@ -22,15 +21,10 @@ export const AzureCredentialsForm = (props: Props) => { credentials, azureCloudOptions, onCredentialsChange, - getSubscriptions, disabled, managedIdentityEnabled, workloadIdentityEnabled, } = props; - const hasRequiredFields = isCredentialsComplete(credentials); - - const [subscriptions, setSubscriptions] = useState>>([]); - const [loadSubscriptionsClicked, onLoadSubscriptions] = useReducer((val) => val + 1, 0); const authTypeOptions = useMemo(() => { let opts: Array> = [ @@ -56,42 +50,7 @@ export const AzureCredentialsForm = (props: Props) => { return opts; }, [managedIdentityEnabled, workloadIdentityEnabled]); - useEffect(() => { - if (!getSubscriptions || !hasRequiredFields) { - updateSubscriptions([]); - return; - } - let canceled = false; - getSubscriptions().then((result) => { - if (!canceled) { - updateSubscriptions(result, loadSubscriptionsClicked); - } - }); - return () => { - canceled = true; - }; - // This effect is intended to be called only once initially and on Load Subscriptions click - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadSubscriptionsClicked]); - - const updateSubscriptions = (received: Array>, autoSelect = false) => { - setSubscriptions(received); - if (getSubscriptions) { - if (autoSelect && !credentials.defaultSubscriptionId && received.length > 0) { - // Selecting the default subscription if subscriptions received but no default subscription selected - onSubscriptionChange(received[0]); - } else if (credentials.defaultSubscriptionId) { - const found = received.find((opt) => opt.value === credentials.defaultSubscriptionId); - if (!found) { - // Unselecting the default subscription if it isn't found among the received subscriptions - onSubscriptionChange(undefined); - } - } - } - }; - const onAuthTypeChange = (selected: SelectableValue) => { - setSubscriptions([]); const defaultAuthType = managedIdentityEnabled ? 'msi' : workloadIdentityEnabled @@ -100,18 +59,15 @@ export const AzureCredentialsForm = (props: Props) => { const updated: AzureCredentials = { ...credentials, authType: selected.value || defaultAuthType, - defaultSubscriptionId: undefined, }; onCredentialsChange(updated); }; const onAzureCloudChange = (selected: SelectableValue) => { if (credentials.authType === 'clientsecret') { - setSubscriptions([]); const updated: AzureCredentials = { ...credentials, azureCloud: selected.value, - defaultSubscriptionId: undefined, }; onCredentialsChange(updated); } @@ -119,11 +75,9 @@ export const AzureCredentialsForm = (props: Props) => { const onTenantIdChange = (event: ChangeEvent) => { if (credentials.authType === 'clientsecret') { - setSubscriptions([]); const updated: AzureCredentials = { ...credentials, tenantId: event.target.value, - defaultSubscriptionId: undefined, }; onCredentialsChange(updated); } @@ -131,11 +85,9 @@ export const AzureCredentialsForm = (props: Props) => { const onClientIdChange = (event: ChangeEvent) => { if (credentials.authType === 'clientsecret') { - setSubscriptions([]); const updated: AzureCredentials = { ...credentials, clientId: event.target.value, - defaultSubscriptionId: undefined, }; onCredentialsChange(updated); } @@ -143,11 +95,9 @@ export const AzureCredentialsForm = (props: Props) => { const onClientSecretChange = (event: ChangeEvent) => { if (credentials.authType === 'clientsecret') { - setSubscriptions([]); const updated: AzureCredentials = { ...credentials, clientSecret: event.target.value, - defaultSubscriptionId: undefined, }; onCredentialsChange(updated); } @@ -155,23 +105,14 @@ export const AzureCredentialsForm = (props: Props) => { const onClientSecretReset = () => { if (credentials.authType === 'clientsecret') { - setSubscriptions([]); const updated: AzureCredentials = { ...credentials, clientSecret: '', - defaultSubscriptionId: undefined, }; onCredentialsChange(updated); } }; - const onSubscriptionChange = (selected: SelectableValue | undefined) => { - const updated: AzureCredentials = { - ...credentials, - defaultSubscriptionId: selected?.value, - }; - onCredentialsChange(updated); - }; const prometheusConfigOverhaulAuth = config.featureToggles.prometheusConfigOverhaulAuth; return ( @@ -283,42 +224,6 @@ export const AzureCredentialsForm = (props: Props) => { )} )} - {getSubscriptions && ( - <> -
-
- Default Subscription -
- setIsOpen(true) }))} - /> +
+
selectedItems.length > 0 && setIsOpen(!isOpen)} + > + + {visibleItems.map((item, index) => ( + { + removeSelectedItem(item); + }} + key={`${item.value}${index}`} + {...getSelectedItemProps({ selectedItem: item, index })} + > + {itemToString(item)} + + ))} + {selectedItems.length > shownItems && !isOpen && ( + + {/* eslint-disable-next-line @grafana/no-untranslated-strings */} + ... + + {selectedItems.slice(shownItems).map((item) => ( +
{itemToString(item)}
+ ))} + + } + > +
{selectedItems.length - shownItems}
+
+
+ )} + 0, + })} + {...getInputProps( + getDropdownProps({ + preventKeyAction: isOpen, + placeholder: selectedItems.length > 0 ? undefined : placeholder, + onFocus: () => setIsOpen(true), + }) + )} + /> +
+
{isOpen && ( diff --git a/packages/grafana-ui/src/components/Combobox/ValuePill.tsx b/packages/grafana-ui/src/components/Combobox/ValuePill.tsx index 3f1a2dce3d8..789436938b8 100644 --- a/packages/grafana-ui/src/components/Combobox/ValuePill.tsx +++ b/packages/grafana-ui/src/components/Combobox/ValuePill.tsx @@ -15,7 +15,7 @@ export const ValuePill = forwardRef(({ children const styles = useStyles2(getValuePillStyles); return ( - {children} + {children} ({ background: theme.colors.background.secondary, padding: theme.spacing(0.25), fontSize: theme.typography.bodySmall.fontSize, + flexShrink: 0, + minWidth: '50px', + + '&:first-child:has(+ div)': { + flexShrink: 1, + }, + }), + + text: css({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', }), separator: css({ diff --git a/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts index 43a8555048e..4f7551ddf2e 100644 --- a/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts +++ b/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts @@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { getFocusStyles } from '../../themes/mixins'; import { getInputStyles } from '../Input/Input'; -export const getMultiComboboxStyles = (theme: GrafanaTheme2) => { +export const getMultiComboboxStyles = (theme: GrafanaTheme2, isOpen: boolean) => { const inputStyles = getInputStyles({ theme }); const focusStyles = getFocusStyles(theme); @@ -34,10 +34,30 @@ export const getMultiComboboxStyles = (theme: GrafanaTheme2) => { outline: 'none', }, }), + inputClosed: css({ + width: 0, + flexGrow: 0, + paddingLeft: 0, + paddingRight: 0, + }), pillWrapper: css({ display: 'inline-flex', - flexWrap: 'wrap', + flexWrap: isOpen ? 'wrap' : 'nowrap', + flexGrow: 1, + minWidth: '50px', gap: theme.spacing(0.5), }), + restNumber: css({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(0, 1), + borderRadius: theme.shape.radius.default, + backgroundColor: theme.colors.background.secondary, + cursor: 'pointer', + '&:hover': { + backgroundColor: theme.colors.action.hover, + }, + }), }; }; diff --git a/packages/grafana-ui/src/components/Combobox/useMeasureMulti.ts b/packages/grafana-ui/src/components/Combobox/useMeasureMulti.ts new file mode 100644 index 00000000000..83a5b0e2871 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/useMeasureMulti.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { useMeasure } from 'react-use'; + +import { measureText } from '../../utils'; + +import { ComboboxOption } from './Combobox'; + +const FONT_SIZE = 12; +const EXTRA_PILL_SIZE = 50; + +/** + * Updates the number of shown items in the multi combobox based on the available width. + */ +export function useMeasureMulti( + selectedItems: Array>, + width?: number | 'auto' +) { + const [shownItems, setShownItems] = useState(selectedItems.length); + const [measureRef, { width: containerWidth }] = useMeasure(); + const [suffixMeasureRef, { width: suffixWidth }] = useMeasure(); + + const finalWidth = width && width !== 'auto' ? width : containerWidth; + + useEffect(() => { + const maxWidth = finalWidth - suffixWidth; + let currWidth = 0; + for (let i = 0; i < selectedItems.length; i++) { + // Measure text width and add size of padding, separator and close button + currWidth += measureText(selectedItems[i].label || '', FONT_SIZE).width + EXTRA_PILL_SIZE; + if (currWidth > maxWidth) { + // If there is no space for that item, show the current number of items, + // but always show at least 1 item + setShownItems(i || 1); + break; + } + if (i === selectedItems.length - 1) { + // If it is the last item, show all items + setShownItems(selectedItems.length); + } + } + }, [finalWidth, suffixWidth, selectedItems, setShownItems]); + + return { measureRef, suffixMeasureRef, shownItems }; +} From c1e078b3a33e70ab6830d1b5bc05324b7bbab1e6 Mon Sep 17 00:00:00 2001 From: Leonor Oliveira <9090754+leonorfmartins@users.noreply.github.com> Date: Thu, 19 Dec 2024 12:38:08 +0100 Subject: [PATCH 24/32] Include library elements on counts endpoint (#98246) --- pkg/storage/unified/federated/stats.go | 6 ++++++ pkg/storage/unified/federated/stats_test.go | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/pkg/storage/unified/federated/stats.go b/pkg/storage/unified/federated/stats.go index 8e5f7c0033c..42eb8566d86 100644 --- a/pkg/storage/unified/federated/stats.go +++ b/pkg/storage/unified/federated/stats.go @@ -63,6 +63,12 @@ func (s *LegacyStatsGetter) GetStats(ctx context.Context, in *resource.ResourceS if err != nil { return err } + + // Legacy library_elements table + err = fn("library_element", "org_id=? AND folder_uid=?", group, "library_elements") + if err != nil { + return err + } return nil }) diff --git a/pkg/storage/unified/federated/stats_test.go b/pkg/storage/unified/federated/stats_test.go index 56181303632..669752b2c29 100644 --- a/pkg/storage/unified/federated/stats_test.go +++ b/pkg/storage/unified/federated/stats_test.go @@ -133,6 +133,10 @@ func TestDirectSQLStats(t *testing.T) { "group": "sql-fallback", "resource": "folders", "count": 1 + }, + { + "group": "sql-fallback", + "resource": "library_elements" } ]`, string(jj)) }) @@ -161,6 +165,10 @@ func TestDirectSQLStats(t *testing.T) { { "group": "sql-fallback", "resource": "folders" + }, + { + "group": "sql-fallback", + "resource": "library_elements" } ]`, string(jj)) }) From a331c7cc3876cad5dfdf092fb9f12b30047b44e5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 11:44:28 +0000 Subject: [PATCH 25/32] Update dependency stylelint-config-sass-guidelines to v12 (#98213) * Update dependency stylelint-config-sass-guidelines to v12 * fix stylelint issues * fix indentation * update snapshots * linting... * linting... --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison --- package.json | 2 +- .../grafana-data/src/themes/createColors.ts | 6 +-- .../src/themes/createTypography.ts | 2 +- packages/grafana-data/src/themes/palette.ts | 46 ++++++++-------- .../uPlot/config/UPlotConfigBuilder.test.ts | 4 +- .../GraphNG/__snapshots__/utils.test.ts.snap | 4 +- .../src/themes/_variables.light.scss.tmpl.ts | 2 +- .../GraphNG/__snapshots__/utils.test.ts.snap | 4 +- .../__snapshots__/util.test.ts.snap | 18 +++---- .../VariableQueryEditor.test.tsx.snap | 2 +- .../barchart/__snapshots__/utils.test.ts.snap | 32 ++++++------ public/sass/_angular.scss | 16 +++--- public/sass/_variables.dark.generated.scss | 22 ++++---- public/sass/_variables.generated.scss | 2 +- public/sass/_variables.light.generated.scss | 50 +++++++++--------- yarn.lock | 52 ++++++++++++++++--- 16 files changed, 150 insertions(+), 114 deletions(-) diff --git a/package.json b/package.json index d565d15940c..38e9e87b487 100644 --- a/package.json +++ b/package.json @@ -231,7 +231,7 @@ "smtp-tester": "^2.1.0", "style-loader": "4.0.0", "stylelint": "16.12.0", - "stylelint-config-sass-guidelines": "11.1.0", + "stylelint-config-sass-guidelines": "12.1.0", "terser-webpack-plugin": "5.3.11", "testing-library-selector": "0.3.1", "tracelib": "1.0.1", diff --git a/packages/grafana-data/src/themes/createColors.ts b/packages/grafana-data/src/themes/createColors.ts index 9db987e83a6..fd8d987a7fa 100644 --- a/packages/grafana-data/src/themes/createColors.ts +++ b/packages/grafana-data/src/themes/createColors.ts @@ -95,7 +95,7 @@ class DarkColors implements ThemeColorsBase> { border = { weak: `rgba(${this.whiteBase}, 0.12)`, - medium: `rgba(${this.whiteBase}, 0.20)`, + medium: `rgba(${this.whiteBase}, 0.2)`, strong: `rgba(${this.whiteBase}, 0.30)`, }; @@ -187,8 +187,8 @@ class LightColors implements ThemeColorsBase> { border = { weak: `rgba(${this.blackBase}, 0.12)`, - medium: `rgba(${this.blackBase}, 0.30)`, - strong: `rgba(${this.blackBase}, 0.40)`, + medium: `rgba(${this.blackBase}, 0.3)`, + strong: `rgba(${this.blackBase}, 0.4)`, }; secondary = { diff --git a/packages/grafana-data/src/themes/createTypography.ts b/packages/grafana-data/src/themes/createTypography.ts index 3f11990c0e7..25c5fa7c91b 100644 --- a/packages/grafana-data/src/themes/createTypography.ts +++ b/packages/grafana-data/src/themes/createTypography.ts @@ -53,7 +53,7 @@ export interface ThemeTypographyInput { htmlFontSize?: number; } -const defaultFontFamily = '"Inter", "Helvetica", "Arial", sans-serif'; +const defaultFontFamily = "'Inter', 'Helvetica', 'Arial', sans-serif"; const defaultFontFamilyMonospace = "'Roboto Mono', monospace"; export function createTypography(colors: ThemeColors, typographyInput: ThemeTypographyInput = {}): ThemeTypography { diff --git a/packages/grafana-data/src/themes/palette.ts b/packages/grafana-data/src/themes/palette.ts index f9d9a780688..5934e144710 100644 --- a/packages/grafana-data/src/themes/palette.ts +++ b/packages/grafana-data/src/themes/palette.ts @@ -1,5 +1,5 @@ export const palette = { - white: '#FFFFFF', + white: '#ffffff', black: '#000000', gray25: '#2c3235', @@ -8,37 +8,37 @@ export const palette = { gray05: '#111217', // old '#0b0c0e', // new from figma, - darkLayer0: '#18181A', + darkLayer0: '#18181a', darkLayer1: '#212124', darkLayer2: '#2a2a2f', // figma used #34343B but a bit too bright - darkBorder1: '#34343B', - darkBorder2: '#64646B', + darkBorder1: '#34343b', + darkBorder2: '#64646b', // Dashboard bg / layer 0 (light theme) - gray90: '#F4F5F5', + gray90: '#f4f5f5', // Card bg / layer 1 - gray100: '#F4F5F5', + gray100: '#f4f5f5', // divider line - gray80: '#D0D1D3', + gray80: '#d0d1d3', // from figma - lightBorder1: '#E4E7E7', + lightBorder1: '#e4e7e7', - blueDarkMain: '#3D71D9', // '#4165F5', - blueDarkText: '#6E9FFF', // '#58a6ff', //'#33a2e5', // '#5790FF', - redDarkMain: '#D10E5C', - redDarkText: '#FF5286', - greenDarkMain: '#1A7F4B', - greenDarkText: '#6CCF8E', - orangeDarkMain: '#FF9900', + blueDarkMain: '#3d71d9', // '#4165F5', + blueDarkText: '#6e9fff', // '#58a6ff', //'#33a2e5', // '#5790FF', + redDarkMain: '#d10e5c', + redDarkText: '#ff5286', + greenDarkMain: '#1a7f4b', + greenDarkText: '#6ccf8e', + orangeDarkMain: '#ff9900', orangeDarkText: '#fbad37', - blueLightMain: '#3871DC', - blueLightText: '#1F62E0', - redLightMain: '#E0226E', - redLightText: '#CF0E5B', - greenLightMain: '#1B855E', - greenLightText: '#0A764E', - orangeLightMain: '#FF9900', - orangeLightText: '#B5510D', + blueLightMain: '#3871dc', + blueLightText: '#1f62e0', + redLightMain: '#e0226e', + redLightText: '#cf0e5B', + greenLightMain: '#1b855e', + greenLightText: '#0a764e', + orangeLightMain: '#ff9900', + orangeLightText: '#b5510d', }; diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts index 2c381e896f4..ea7dd106c1b 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -414,7 +414,7 @@ describe('UPlotConfigBuilder', () => { "axes": [ { "filter": undefined, - "font": "12px "Inter", "Helvetica", "Arial", sans-serif", + "font": "12px 'Inter', 'Helvetica', 'Arial', sans-serif", "gap": 5, "grid": { "show": false, @@ -423,7 +423,7 @@ describe('UPlotConfigBuilder', () => { }, "incrs": undefined, "label": "test label", - "labelFont": "12px "Inter", "Helvetica", "Arial", sans-serif", + "labelFont": "12px 'Inter', 'Helvetica', 'Arial', sans-serif", "labelGap": 8, "labelSize": 20, "rotate": undefined, diff --git a/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap index 2aff27a97fa..70d8660a656 100644 --- a/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap +++ b/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap @@ -5,7 +5,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "axes": [ { "filter": undefined, - "font": "12px "Inter", "Helvetica", "Arial", sans-serif", + "font": "12px 'Inter', 'Helvetica', 'Arial', sans-serif", "gap": 5, "grid": { "show": true, @@ -33,7 +33,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` }, { "filter": undefined, - "font": "12px "Inter", "Helvetica", "Arial", sans-serif", + "font": "12px 'Inter', 'Helvetica', 'Arial', sans-serif", "gap": 5, "grid": { "show": true, diff --git a/packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts b/packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts index a88d28df02c..2ba873ec5fb 100644 --- a/packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts @@ -148,7 +148,7 @@ $popover-shadow: ${theme.shadows.z3}; $graph-tooltip-bg: $gray-5; $tooltipLinkColor: color.adjust($tooltipColor, $lightness: 5%); -$tooltipExternalLinkColor: #6E9FFF; +$tooltipExternalLinkColor: #6e9fff; $popover-error-bg: $red-base; $popover-help-bg: $tooltipBackground; diff --git a/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap b/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap index 3c8f6a99d86..3bb1df8de0a 100644 --- a/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap +++ b/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap @@ -5,7 +5,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "axes": [ { "filter": undefined, - "font": "12px "Inter", "Helvetica", "Arial", sans-serif", + "font": "12px 'Inter', 'Helvetica', 'Arial', sans-serif", "gap": 5, "grid": { "show": true, @@ -33,7 +33,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` }, { "filter": undefined, - "font": "12px "Inter", "Helvetica", "Arial", sans-serif", + "font": "12px 'Inter', 'Helvetica', 'Arial', sans-serif", "gap": 5, "grid": { "show": true, diff --git a/public/app/features/alerting/unified/components/rule-editor/__snapshots__/util.test.ts.snap b/public/app/features/alerting/unified/components/rule-editor/__snapshots__/util.test.ts.snap index a6154bf3fab..71e3ea44f31 100644 --- a/public/app/features/alerting/unified/components/rule-editor/__snapshots__/util.test.ts.snap +++ b/public/app/features/alerting/unified/components/rule-editor/__snapshots__/util.test.ts.snap @@ -11,7 +11,7 @@ exports[`getThresholdsForQueries should work for classic_condition 1`] = ` "value": -Infinity, }, { - "color": "#D10E5C", + "color": "#d10e5c", "value": 0, }, ], @@ -32,7 +32,7 @@ exports[`getThresholdsForQueries should work for lt and gt 1`] = ` "value": -Infinity, }, { - "color": "#D10E5C", + "color": "#d10e5c", "value": 0, }, ], @@ -53,7 +53,7 @@ exports[`getThresholdsForQueries should work for lt and gt 2`] = ` "value": -Infinity, }, { - "color": "#D10E5C", + "color": "#d10e5c", "value": 0, }, ], @@ -70,11 +70,11 @@ exports[`getThresholdsForQueries should work for outside_range 1`] = ` "mode": "absolute", "steps": [ { - "color": "#D10E5C", + "color": "#d10e5c", "value": -Infinity, }, { - "color": "#D10E5C", + "color": "#d10e5c", "value": 0, }, { @@ -82,7 +82,7 @@ exports[`getThresholdsForQueries should work for outside_range 1`] = ` "value": 0, }, { - "color": "#D10E5C", + "color": "#d10e5c", "value": 10, }, ], @@ -103,7 +103,7 @@ exports[`getThresholdsForQueries should work for threshold condition 1`] = ` "value": -Infinity, }, { - "color": "#D10E5C", + "color": "#d10e5c", "value": 0, }, ], @@ -124,11 +124,11 @@ exports[`getThresholdsForQueries should work for within_range 1`] = ` "value": -Infinity, }, { - "color": "#D10E5C", + "color": "#d10e5c", "value": 0, }, { - "color": "#D10E5C", + "color": "#d10e5c", "value": 10, }, { diff --git a/public/app/plugins/datasource/cloud-monitoring/components/__snapshots__/VariableQueryEditor.test.tsx.snap b/public/app/plugins/datasource/cloud-monitoring/components/__snapshots__/VariableQueryEditor.test.tsx.snap index d788e55c354..9deeaac0e1a 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/__snapshots__/VariableQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/cloud-monitoring/components/__snapshots__/VariableQueryEditor.test.tsx.snap @@ -19,7 +19,7 @@ exports[`VariableQueryEditor renders correctly 1`] = `
Date: Thu, 19 Dec 2024 11:48:57 +0000 Subject: [PATCH 26/32] Update dependency marked to v15 (#98199) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-data/package.json | 2 +- packages/grafana-prometheus/package.json | 2 +- yarn.lock | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 38e9e87b487..957c9c0e82d 100644 --- a/package.json +++ b/package.json @@ -345,7 +345,7 @@ "lru-cache": "11.0.2", "lru-memoize": "^1.1.0", "lucene": "^2.1.1", - "marked": "12.0.2", + "marked": "15.0.4", "memoize-one": "6.0.0", "micro-memoize": "^4.1.2", "ml-regression-polynomial": "^3.0.0", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 6269f1c8bf7..368a97dfbb1 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -46,7 +46,7 @@ "fast_array_intersect": "1.1.0", "history": "4.10.1", "lodash": "4.17.21", - "marked": "12.0.2", + "marked": "15.0.4", "marked-mangle": "1.1.10", "moment": "2.30.1", "moment-timezone": "0.5.46", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 668cafc5905..3b26c38fa02 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -57,7 +57,7 @@ "eventemitter3": "5.0.1", "lodash": "4.17.21", "lru-cache": "11.0.2", - "marked": "12.0.2", + "marked": "15.0.4", "marked-mangle": "1.1.10", "moment": "2.30.1", "moment-timezone": "0.5.46", diff --git a/yarn.lock b/yarn.lock index 65f59fff2f7..dcd19d91917 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3312,7 +3312,7 @@ __metadata: fast_array_intersect: "npm:1.1.0" history: "npm:4.10.1" lodash: "npm:4.17.21" - marked: "npm:12.0.2" + marked: "npm:15.0.4" marked-mangle: "npm:1.1.10" moment: "npm:2.30.1" moment-timezone: "npm:0.5.46" @@ -3719,7 +3719,7 @@ __metadata: jest-matcher-utils: "npm:29.7.0" lodash: "npm:4.17.21" lru-cache: "npm:11.0.2" - marked: "npm:12.0.2" + marked: "npm:15.0.4" marked-mangle: "npm:1.1.10" moment: "npm:2.30.1" moment-timezone: "npm:0.5.46" @@ -17743,7 +17743,7 @@ __metadata: lru-cache: "npm:11.0.2" lru-memoize: "npm:^1.1.0" lucene: "npm:^2.1.1" - marked: "npm:12.0.2" + marked: "npm:15.0.4" memoize-one: "npm:6.0.0" micro-memoize: "npm:^4.1.2" mini-css-extract-plugin: "npm:2.9.2" @@ -21568,12 +21568,12 @@ __metadata: languageName: node linkType: hard -"marked@npm:12.0.2": - version: 12.0.2 - resolution: "marked@npm:12.0.2" +"marked@npm:15.0.4": + version: 15.0.4 + resolution: "marked@npm:15.0.4" bin: marked: bin/marked.js - checksum: 10/24d4fc58d37c1779197fa7f93c504d8c71d4df54eb69cbbc14a55ba2a8e2ad83d723801fc25452c21ce74b38a483c5863c53449f130253a597be9e9c1d3e7e2b + checksum: 10/c4dfc1d42c655c411d1bbe25851585d22ba24e8edc296d3e4123c6685a4ae4c2bef05c144678a737d7ece7cc489921ced3261800b715a30fcc1f4e24bf1e69d5 languageName: node linkType: hard From 105251425fbccf67b75e38ce9427d2038a09eb2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ida=20=C5=A0tambuk?= Date: Thu, 19 Dec 2024 12:53:39 +0100 Subject: [PATCH 27/32] CloudWatch: Fix interpolation of log groups when fetching fields (#98054) --- .../completion/CompletionItemProvider.ts | 24 ++++---------- .../CloudWatchLogsLanguageProvider.ts | 22 ++----------- .../completion/PPLCompletionItemProvider.ts | 20 ++++------- .../logs/completion/CompletionItemProvider.ts | 20 ++++------- .../datasource/cloudwatch/language/utils.ts | 33 +++++++++++++++++++ .../cloudwatch/resources/ResourcesAPI.ts | 10 ++---- .../datasource/cloudwatch/resources/types.ts | 5 --- 7 files changed, 58 insertions(+), 76 deletions(-) create mode 100644 public/app/plugins/datasource/cloudwatch/language/utils.ts diff --git a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs-sql/completion/CompletionItemProvider.ts b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs-sql/completion/CompletionItemProvider.ts index 0bb7db76d0d..ea8063ff196 100644 --- a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs-sql/completion/CompletionItemProvider.ts +++ b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs-sql/completion/CompletionItemProvider.ts @@ -7,6 +7,7 @@ import { CompletionItemProvider } from '../../monarch/CompletionItemProvider'; import { LinkedToken } from '../../monarch/LinkedToken'; import { TRIGGER_SUGGEST } from '../../monarch/commands'; import { SuggestionKind, CompletionItemPriority, StatementPosition } from '../../monarch/types'; +import { fetchLogGroupFields } from '../../utils'; import { ASC, BY, @@ -268,7 +269,12 @@ export class LogsSQLCompletionItemProvider extends CompletionItemProvider { break; case SuggestionKind.Field: - const fields = await this.fetchFields(this.queryContext.logGroups || [], this.queryContext.region); + const fields = await fetchLogGroupFields( + this.queryContext.logGroups || [], + this.queryContext.region, + this.templateSrv, + this.resources + ); fields.forEach((field) => { if (field !== '') { addSuggestion(field, { @@ -295,20 +301,4 @@ export class LogsSQLCompletionItemProvider extends CompletionItemProvider { return suggestions; } - - private fetchFields = async (logGroups: LogGroup[], region: string): Promise => { - if (logGroups.length === 0) { - return []; - } - - const results = await Promise.all( - logGroups.map((logGroup) => - this.resources - .getLogGroupFields({ logGroupName: logGroup.name, arn: logGroup.arn, region }) - .then((fields) => fields.filter((f) => f).map((f) => f.value.name ?? '')) - ) - ); - // Deduplicate fields - return [...new Set(results.flat())]; - }; } diff --git a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts index f2e889f9c22..1f182989d8c 100644 --- a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts +++ b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-logs/CloudWatchLogsLanguageProvider.ts @@ -7,7 +7,7 @@ import { CompletionItemGroup, SearchFunctionType, Token, TypeaheadInput, Typeahe import { CloudWatchDatasource } from '../../datasource'; import { CloudWatchQuery, LogGroup } from '../../types'; -import { interpolateStringArrayUsingSingleOrMultiValuedVariable } from '../../utils/templateVariableUtils'; +import { fetchLogGroupFields } from '../utils'; import syntax, { AGGREGATION_FUNCTIONS_STATS, @@ -131,24 +131,6 @@ export class CloudWatchLogsLanguageProvider extends LanguageProvider { }; } - private fetchFields = async (logGroups: LogGroup[], region: string): Promise => { - const interpolatedLogGroups = interpolateStringArrayUsingSingleOrMultiValuedVariable( - this.templateSrv, - logGroups.map((lg) => lg.name), - {}, - 'text' - ); - const results = await Promise.all( - interpolatedLogGroups.map((logGroupName) => - this.datasource.resources - .getLogGroupFields({ logGroupName, region }) - .then((fields) => fields.filter((f) => f).map((f) => f.value.name ?? '')) - ) - ); - - return results.flat(); - }; - private handleKeyword = async (context?: TypeaheadContext): Promise => { const suggs = await this.getFieldCompletionItems(context?.logGroups, context?.region || 'default'); const functionSuggestions: CompletionItemGroup[] = [ @@ -312,7 +294,7 @@ export class CloudWatchLogsLanguageProvider extends LanguageProvider { return { suggestions: [] }; } - const fields = await this.fetchFields(logGroups, region); + const fields = await fetchLogGroupFields(logGroups, region, this.templateSrv, this.datasource.resources); return { suggestions: [ { diff --git a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-ppl/completion/PPLCompletionItemProvider.ts b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-ppl/completion/PPLCompletionItemProvider.ts index 82a46a0f904..fee0f2e23fe 100644 --- a/public/app/plugins/datasource/cloudwatch/language/cloudwatch-ppl/completion/PPLCompletionItemProvider.ts +++ b/public/app/plugins/datasource/cloudwatch/language/cloudwatch-ppl/completion/PPLCompletionItemProvider.ts @@ -7,6 +7,7 @@ import { CompletionItemProvider } from '../../monarch/CompletionItemProvider'; import { LinkedToken } from '../../monarch/LinkedToken'; import { TRIGGER_SUGGEST } from '../../monarch/commands'; import { CompletionItem, CompletionItemPriority, StatementPosition, SuggestionKind } from '../../monarch/types'; +import { fetchLogGroupFields } from '../../utils'; import { BOOLEAN_LITERALS, CONDITION_FUNCTIONS, @@ -252,7 +253,12 @@ export class PPLCompletionItemProvider extends CompletionItemProvider { ): Promise { if (this.queryContext.logGroups && this.queryContext.logGroups.length > 0) { try { - let fields = await this.fetchFields(this.queryContext.logGroups, this.queryContext.region); + let fields = await fetchLogGroupFields( + this.queryContext.logGroups, + this.queryContext.region, + this.templateSrv, + this.resources + ); fields.forEach((field) => { if (field !== '') { addSuggestion(field, { @@ -269,16 +275,4 @@ export class PPLCompletionItemProvider extends CompletionItemProvider { } } } - - private async fetchFields(logGroups: LogGroup[], region: string): Promise { - const results = await Promise.all( - logGroups.map((logGroup) => - this.resources - .getLogGroupFields({ logGroupName: logGroup.name, arn: logGroup.arn, region }) - .then((fields) => fields.filter((f) => f).map((f) => f.value.name ?? '')) - ) - ); - // Deduplicate fields - return [...new Set(results.flat())]; - } } diff --git a/public/app/plugins/datasource/cloudwatch/language/logs/completion/CompletionItemProvider.ts b/public/app/plugins/datasource/cloudwatch/language/logs/completion/CompletionItemProvider.ts index d5e9dc3c318..d37615e2713 100644 --- a/public/app/plugins/datasource/cloudwatch/language/logs/completion/CompletionItemProvider.ts +++ b/public/app/plugins/datasource/cloudwatch/language/logs/completion/CompletionItemProvider.ts @@ -7,6 +7,7 @@ import { CompletionItemProvider } from '../../monarch/CompletionItemProvider'; import { LinkedToken } from '../../monarch/LinkedToken'; import { TRIGGER_SUGGEST } from '../../monarch/commands'; import { CompletionItem, CompletionItemPriority, StatementPosition, SuggestionKind } from '../../monarch/types'; +import { fetchLogGroupFields } from '../../utils'; import { LOGS_COMMANDS, LOGS_FUNCTION_OPERATORS, SORT_DIRECTION_KEYWORDS } from '../language'; import { getStatementPosition } from './statementPosition'; @@ -86,7 +87,12 @@ export class LogsCompletionItemProvider extends CompletionItemProvider { }); if (this.queryContext.logGroups && this.queryContext.logGroups.length > 0) { - let fields = await this.fetchFields(this.queryContext.logGroups, this.queryContext.region); + let fields = await fetchLogGroupFields( + this.queryContext.logGroups, + this.queryContext.region, + this.templateSrv, + this.resources + ); fields.push('@log'); fields.forEach((field) => { if (field !== '') { @@ -133,16 +139,4 @@ export class LogsCompletionItemProvider extends CompletionItemProvider { return suggestions; } - - private fetchFields = async (logGroups: LogGroup[], region: string): Promise => { - const results = await Promise.all( - logGroups.map((logGroup) => - this.resources - .getLogGroupFields({ logGroupName: logGroup.name, arn: logGroup.arn, region }) - .then((fields) => fields.filter((f) => f).map((f) => f.value.name ?? '')) - ) - ); - // Deduplicate fields - return [...new Set(results.flat())]; - }; } diff --git a/public/app/plugins/datasource/cloudwatch/language/utils.ts b/public/app/plugins/datasource/cloudwatch/language/utils.ts new file mode 100644 index 00000000000..472e3cc59b6 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/language/utils.ts @@ -0,0 +1,33 @@ +import { TemplateSrv } from '@grafana/runtime'; + +import { LogGroup } from '../dataquery.gen'; +import { ResourcesAPI } from '../resources/ResourcesAPI'; +import { interpolateStringArrayUsingSingleOrMultiValuedVariable } from '../utils/templateVariableUtils'; + +export const fetchLogGroupFields = async ( + logGroups: LogGroup[], + region: string, + templateSrv: TemplateSrv, + resources: ResourcesAPI +): Promise => { + if (logGroups.length === 0) { + return []; + } + + const interpolatedLogGroups = interpolateStringArrayUsingSingleOrMultiValuedVariable( + templateSrv, + logGroups.map((lg) => lg.name), + {}, + 'text' + ); + + const results = await Promise.all( + interpolatedLogGroups.map((logGroupName) => + resources + .getLogGroupFields(region, logGroupName) + .then((fields) => fields.filter((f) => f).map((f) => f.value.name ?? '')) + ) + ); + // Deduplicate fields + return [...new Set(results.flat())]; +}; diff --git a/public/app/plugins/datasource/cloudwatch/resources/ResourcesAPI.ts b/public/app/plugins/datasource/cloudwatch/resources/ResourcesAPI.ts index b518ce9d29f..5ba8e9236df 100644 --- a/public/app/plugins/datasource/cloudwatch/resources/ResourcesAPI.ts +++ b/public/app/plugins/datasource/cloudwatch/resources/ResourcesAPI.ts @@ -12,7 +12,6 @@ import { ResourceResponse, DescribeLogGroupsRequest, LogGroupResponse, - GetLogGroupFieldsRequest, GetMetricsRequest, GetDimensionKeysRequest, GetDimensionValuesRequest, @@ -79,15 +78,10 @@ export class ResourcesAPI extends CloudWatchRequest { }); } - getLogGroupFields({ - region, - arn, - logGroupName, - }: GetLogGroupFieldsRequest): Promise>> { + getLogGroupFields(region: string, logGroupName: string): Promise>> { return this.memoizedGetRequest>>('log-group-fields', { region: this.templateSrv.replace(this.getActualRegion(region)), - logGroupName: this.templateSrv.replace(logGroupName, {}), - logGroupArn: this.templateSrv.replace(arn), + logGroupName: logGroupName, }); } diff --git a/public/app/plugins/datasource/cloudwatch/resources/types.ts b/public/app/plugins/datasource/cloudwatch/resources/types.ts index 7e7f760252f..290da62c171 100644 --- a/public/app/plugins/datasource/cloudwatch/resources/types.ts +++ b/public/app/plugins/datasource/cloudwatch/resources/types.ts @@ -12,11 +12,6 @@ export interface ResourceRequest { accountId?: string; } -export interface GetLogGroupFieldsRequest extends ResourceRequest { - arn?: string; - logGroupName: string; -} - export interface GetDimensionKeysRequest extends ResourceRequest { metricName?: string; namespace?: string; From 749a2f52bbf233c9bca04a2ff4d310c0a6362803 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 12:05:09 +0000 Subject: [PATCH 28/32] Update dependency @grafana/plugin-e2e to v1.14.1 (#98247) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index dcd19d91917..25c787cf84a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3605,16 +3605,16 @@ __metadata: linkType: soft "@grafana/plugin-e2e@npm:^1.11.0": - version: 1.14.0 - resolution: "@grafana/plugin-e2e@npm:1.14.0" + version: 1.14.1 + resolution: "@grafana/plugin-e2e@npm:1.14.1" dependencies: - "@grafana/e2e-selectors": "npm:^11.5.0-213998" + "@grafana/e2e-selectors": "npm:^11.5.0-214310" semver: "npm:^7.5.4" uuid: "npm:^11.0.2" yaml: "npm:^2.3.4" peerDependencies: "@playwright/test": ^1.41.2 - checksum: 10/d854f1bcf218072fd79942cb145facfd3b2f8f2d0a5321fce8bc89ecd2440b21b18e34d7ccbfb8c04eb39f8e6f10fe7b2653966dc671b5936c9074340d9a7d32 + checksum: 10/bd2a1688ab5d49668cd72102e91107f84a9f8dfcddbd5ca3cd2a551a5219870c50c73355f8e3a599190c1f341680d1bb6f13a3cdeb84c69cfa6ba061ff7af59f languageName: node linkType: hard From 73dda344f92e91f3341f3067410c1fe45670021d Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Thu, 19 Dec 2024 13:07:32 +0100 Subject: [PATCH 29/32] Bump authlib (#98234) * Bump authlib --- go.mod | 2 +- go.sum | 4 ++-- pkg/apimachinery/go.mod | 2 +- pkg/apimachinery/go.sum | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 8fcec037399..fc541b20c2a 100644 --- a/go.mod +++ b/go.mod @@ -74,7 +74,7 @@ require ( github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad github.com/grafana/alerting v0.0.0-20241211182001-0f317eb6b2f7 // @grafana/alerting-backend - github.com/grafana/authlib v0.0.0-20241218111825-19796465b76a // @grafana/identity-access-team + github.com/grafana/authlib v0.0.0-20241219093220-08aa05b9cf26 // @grafana/identity-access-team github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 // @grafana/identity-access-team github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d // @grafana/dataviz-squad github.com/grafana/cuetsy v0.1.11 // @grafana/grafana-as-code diff --git a/go.sum b/go.sum index dc473a71b0c..1f27f212e76 100644 --- a/go.sum +++ b/go.sum @@ -2293,8 +2293,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/alerting v0.0.0-20241211182001-0f317eb6b2f7 h1:VGLUQ2mwzlF1NGwTxpSfv1RnuOsDlNh/NT5KRvhZ0sQ= github.com/grafana/alerting v0.0.0-20241211182001-0f317eb6b2f7/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU= -github.com/grafana/authlib v0.0.0-20241218111825-19796465b76a h1:1c672QJhHWnwWdouqVIqn38ArRhKRzkp3v71H+Tmqas= -github.com/grafana/authlib v0.0.0-20241218111825-19796465b76a/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI= +github.com/grafana/authlib v0.0.0-20241219093220-08aa05b9cf26 h1:kWe1MsWAtFQvmnbwnQNJdaGCTqbGNVuowOtLmhO5qEY= +github.com/grafana/authlib v0.0.0-20241219093220-08aa05b9cf26/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI= github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk= github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A= github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk= diff --git a/pkg/apimachinery/go.mod b/pkg/apimachinery/go.mod index 6f02e028f78..02997613ece 100644 --- a/pkg/apimachinery/go.mod +++ b/pkg/apimachinery/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/pkg/apimachinery go 1.23.1 require ( - github.com/grafana/authlib v0.0.0-20241218111825-19796465b76a // @grafana/identity-access-team + github.com/grafana/authlib v0.0.0-20241219093220-08aa05b9cf26 // @grafana/identity-access-team github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 // @grafana/identity-access-team github.com/stretchr/testify v1.10.0 k8s.io/apimachinery v0.31.3 diff --git a/pkg/apimachinery/go.sum b/pkg/apimachinery/go.sum index eecbe63046d..341c225af1e 100644 --- a/pkg/apimachinery/go.sum +++ b/pkg/apimachinery/go.sum @@ -28,8 +28,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/grafana/authlib v0.0.0-20241218111825-19796465b76a h1:1c672QJhHWnwWdouqVIqn38ArRhKRzkp3v71H+Tmqas= -github.com/grafana/authlib v0.0.0-20241218111825-19796465b76a/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI= +github.com/grafana/authlib v0.0.0-20241219093220-08aa05b9cf26 h1:kWe1MsWAtFQvmnbwnQNJdaGCTqbGNVuowOtLmhO5qEY= +github.com/grafana/authlib v0.0.0-20241219093220-08aa05b9cf26/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI= github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk= github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= From 9a5a9fe8a26fc7c2881d1e0a2787764b14297937 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 12:19:28 +0000 Subject: [PATCH 30/32] Update dependency knip to v5.41.1 (#98249) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 25c787cf84a..b27b82374e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20701,8 +20701,8 @@ __metadata: linkType: hard "knip@npm:^5.10.0": - version: 5.41.0 - resolution: "knip@npm:5.41.0" + version: 5.41.1 + resolution: "knip@npm:5.41.1" dependencies: "@nodelib/fs.walk": "npm:1.2.8" "@snyk/github-codeowners": "npm:1.1.0" @@ -20726,7 +20726,7 @@ __metadata: bin: knip: bin/knip.js knip-bun: bin/knip-bun.js - checksum: 10/a30b986ca4331b735f188c9217cb551b2698ddbed54bc98f1bf3c1fe1e85b3aff589244dafe60d6b5c7a8ee5133726d69366965cc3ae6299b43c33787155a7a2 + checksum: 10/683afc2abf205598d015106175dcdd8847e99db4a1e9daff160d6144032b5ece39aca82e23ba9e33f41b7df95858389edc954b07be230a87736197305cd2511e languageName: node linkType: hard From 9a17954eafd3cab62458ccd9284605eaec8c84f0 Mon Sep 17 00:00:00 2001 From: antonio <45235678+tonypowa@users.noreply.github.com> Date: Thu, 19 Dec 2024 13:22:03 +0100 Subject: [PATCH 31/32] tutorial>alerting:get started with grafana alerting part 3 (#98093) * tutorial>alerting:get started with grafana alerting part 3 * draft 1 * call-to-action * typos * all pretty, no pity * delete sentence * Update docs/sources/alerting/fundamentals/notifications/group-alert-notifications.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/tutorials/alerting-get-started-pt2/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/tutorials/alerting-get-started-pt2/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/tutorials/alerting-get-started-pt2/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/tutorials/alerting-get-started-pt3/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/tutorials/alerting-get-started-pt3/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/tutorials/alerting-get-started-pt3/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/tutorials/alerting-get-started-pt3/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/tutorials/alerting-get-started-pt3/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * admon * pretty again * links * format * group interval note * Update docs/sources/tutorials/alerting-get-started-pt3/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * rmved note --------- Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> --- .../group-alert-notifications.md | 2 +- .../alerting-get-started-pt2/index.md | 33 +- .../alerting-get-started-pt3/index.md | 448 ++++++++++++++++++ .../tutorials/alerting-get-started/index.md | 6 +- 4 files changed, 476 insertions(+), 13 deletions(-) create mode 100644 docs/sources/tutorials/alerting-get-started-pt3/index.md diff --git a/docs/sources/alerting/fundamentals/notifications/group-alert-notifications.md b/docs/sources/alerting/fundamentals/notifications/group-alert-notifications.md index e78920d6e76..70a6c7ab1e6 100644 --- a/docs/sources/alerting/fundamentals/notifications/group-alert-notifications.md +++ b/docs/sources/alerting/fundamentals/notifications/group-alert-notifications.md @@ -61,7 +61,7 @@ If you want to group alerts by other labels, something other than the alert rule ### A single group for all alerts -If you want to group all alerts handled by the notification policy in a single group (without grouping notifications by alert rule or other labels), you can do so by leaving `Group by` empty. +If you want to group all alerts handled by the notification policy in a single group (without grouping notifications by alert rule or other labels), leave `Group by` empty in the Default policy. ### Disable grouping diff --git a/docs/sources/tutorials/alerting-get-started-pt2/index.md b/docs/sources/tutorials/alerting-get-started-pt2/index.md index e0564de272c..a26d3b8fd57 100644 --- a/docs/sources/tutorials/alerting-get-started-pt2/index.md +++ b/docs/sources/tutorials/alerting-get-started-pt2/index.md @@ -10,10 +10,10 @@ labels: - cloud tags: - beginner -title: Get started with Grafana Alerting - Part 2 +title: Get started with Grafana Alerting - Part 2 of 3 weight: 50 killercoda: - title: Get started with Grafana Alerting - Part 2 + title: Get started with Grafana Alerting - Part 2 of 3 description: Learn to use alert instances and route notifications by labels to contacts, building on your alerting skills in Grafana for more advanced workflows — Part 2. backend: imageid: ubuntu @@ -21,7 +21,7 @@ killercoda: -# Get started with Grafana Alerting - Part 2 +# Get started with Grafana Alerting - Part 2 of 3 The Get started with Grafana Alerting tutorial Part 2 is a continuation of [Get started with Grafana Alerting tutorial Part 1](http://www.grafana.com/tutorials/alerting-get-started/). @@ -169,7 +169,7 @@ In the above diagram, alert instances and notification policies are matched by l ## Create notification policies -Create a notification policy if you want to handle metrics returned by alert rules separately by routing each alert instance to a specific contact point. In Grafana, click on the icon at the top left corner of the screen to access the navigation menu. +Create a notification policy if you want to handle metrics returned by alert rules separately by routing each alert instance to a specific contact point. @@ -237,7 +237,6 @@ Grafana includes a [test data source](https://grafana.com/docs/grafana/latest/da 1. Select **TestData** data source from the drop-down menu. 1. From **Scenario** select **CSV Content**. -1. In the Query editor, switch to **Code** mode by clicking the button on the right. 1. Copy in the following CSV data: ``` @@ -252,7 +251,7 @@ Grafana includes a [test data source](https://grafana.com/docs/grafana/latest/da - Keep `Last` as the value for the reducer function (`WHEN`), and `1000` as the threshold value. This is the value above which the alert rule should trigger. -1. Click **Preview** to run the queries. +1. Click **Preview alert rule condition** to run the queries. It should return two series.`desktop` in Firing state, and `mobile` in Normal state. The values `1`, and `0` mean that the condition is either `true` or `false`. @@ -267,8 +266,8 @@ In the [life cycle](http://grafana.com/docs/grafana/next/alerting/fundamentals/a To set up evaluation behavior: -1. In **Folder**, click **+ New folder** and enter a name. For example: `web-traffic-alerts`. This folder will contain our alerts. -1. In the **Evaluation group**, repeat the above step to create a new evaluation group. We will name it `1m` (referring to “1 minute”). +1. In **Folder**, click **+ New folder** and enter a name. For example: `web-traffic-alerts`. This folder contains our alert rules. +1. In **Evaluation group**, repeat the above step to create a new evaluation group. Name it `1m` (referring to “1 minute”). 1. Choose an Evaluation interval (how often the alert will be evaluated). Choose `1m`. 1. Set the pending period to `0s` (zero seconds), so the alert rule fires the moment the condition is met. @@ -320,6 +319,22 @@ In this tutorial, you have learned how Grafana Alerting can route individual ale If you run into any problems, you are welcome to post questions in our [Grafana Community forum](https://community.grafana.com/). -Enjoy your monitoring! +## Learn more in [Grafana Alerting Part 3](http://www.grafana.com/tutorials/alerting-get-started-pt3/) + + + +{{< admonition type="tip" >}} + +In [Get started with Grafana Alerting - Part 3](http://www.grafana.com/tutorials/alerting-get-started-pt3/) you learn how to group alert notifications effectively. + +{{< /admonition >}} + + + +{{< docs/ignore >}} + +In [Get started with Grafana Alerting - Part 3](http://www.grafana.com/tutorials/alerting-get-started-pt3/) you learn how to group alert notifications effectively. + +{{< /docs/ignore >}} diff --git a/docs/sources/tutorials/alerting-get-started-pt3/index.md b/docs/sources/tutorials/alerting-get-started-pt3/index.md new file mode 100644 index 00000000000..7d7e6cba332 --- /dev/null +++ b/docs/sources/tutorials/alerting-get-started-pt3/index.md @@ -0,0 +1,448 @@ +--- +Feedback Link: https://github.com/grafana/tutorials/issues/new +categories: + - alerting +description: Learn how to group alert notifications effectively to reduce noise and streamline communication in Grafana Alerting — Part 3. +labels: + products: + - enterprise + - oss + - cloud +tags: + - intermediate +title: Get started with Grafana Alerting - Part 3 +weight: 60 +killercoda: + title: Get started with Grafana Alerting - Part 3 + description: Learn how to group alert notifications effectively to reduce noise and streamline communication in Grafana Alerting — Part 3. + backend: + imageid: ubuntu +refs: + alert-labels: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/alert-rules/annotation-label/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/ + notification-policies: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/notifications/notification-policies/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/notification-policies/ + alert-grouping: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/notifications/group-alert-notifications/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/group-alert-notifications/ +--- + + + +# Get started with Grafana Alerting - Part 3 + +The Get started with Grafana Alerting tutorial Part 3 is a continuation of [Get started with Grafana Alerting tutorial Part 2](http://www.grafana.com/tutorials/alerting-get-started-pt2/). + +Alert grouping in Grafana Alerting reduces notification noise by combining related alerts into a single, concise notification. This is essential for on-call engineers, ensuring they focus on resolving incidents instead of sorting through a flood of notifications. + +Grouping is configured by using labels in the notification policy that reference the labels that are generated by the alert instances. With notification policies, you can also configure how often notifications are sent for each group of alerts. + +In this tutorial, you will: + +- Learn how alert rule grouping works. +- Create a notification policy to handle grouping. +- Define an alert rule for a real-world scenario. +- Receive and review grouped alert notifications. + + + + + + +{{< docs/ignore >}} + +## Set up the Grafana stack + +{{< /docs/ignore >}} + +## Before you begin + +There are different ways you can follow along with this tutorial. + +- **Grafana Cloud** + + - As a Grafana Cloud user, you don't have to install anything. [Create your free account](http://www.grafana.com/auth/sign-up/create-user). + + Continue to [How alert rule grouping works](#how-alert-rule-grouping-works). + +- **Interactive learning environment** + + - Alternatively, you can try out this example in our interactive learning environment: [Get started with Grafana Alerting - Part 3](https://killercoda.com/grafana-labs/course/grafana/alerting-get-started-pt3/). It's a fully configured environment with all the dependencies already installed. + +- **Grafana OSS** + + - If you opt to run a Grafana stack locally, ensure you have the following applications installed: + + - [Docker Compose](https://docs.docker.com/get-docker/) (included in Docker for Desktop for macOS and Windows) + - [Git](https://git-scm.com/) + +### Set up the Grafana stack (OSS users) + + + +To demonstrate the observation of data using the Grafana stack, download and run the following files. + +1. Clone the [tutorial environment repository](https://www.github.com/grafana/tutorial-environment). + + + + ``` + git clone https://github.com/grafana/tutorial-environment.git + ``` + + + +1. Change to the directory where you cloned the repository: + + + + ``` + cd tutorial-environment + ``` + + + +1. Run the Grafana stack: + + + + ``` + docker compose up -d + ``` + + + + {{< docs/ignore >}} + + + + ```bash + docker-compose up -d + ``` + + + + {{< /docs/ignore >}} + + The first time you run `docker compose up -d`, Docker downloads all the necessary resources for the tutorial. This might take a few minutes, depending on your internet connection. + + + + {{< admonition type="note" >}} + If you already have Grafana, Loki, or Prometheus running on your system, you might see errors, because the Docker image is trying to use ports that your local installations are already using. If this is the case, stop the services, then run the command again. + {{< /admonition >}} + + + + {{< docs/ignore >}} + + NOTE: + + If you already have Grafana, Loki, or Prometheus running on your system, you might see errors, because the Docker image is trying to use ports that your local installations are already using. If this is the case, stop the services, then run the command again. + + {{< /docs/ignore >}} + + + + +## How alert rule grouping works + +Alert notification grouping is configured with **labels** and **timing options**: + +- **Labels** map the alert rule with the notification policy and define the grouping. +- **Timing options** control when and how often notifications are sent. + +{{< figure src="/media/docs/alerting/alerting-notification-policy-diagram-with-labels-v3.png" max-width="750px" alt="A diagram about the components of a notification policy, including labels and groups" >}} + +### Types of Labels + +1. **Reserved labels** (default): + + - Automatically generated by Grafana, e.g., `alertname`, `grafana_folder`. + - Example: `alertname="High CPU usage"`. + +1. **User-configured labels**: + + - Added manually to the alert rule. + - Example: `severity`, `priority`. + +1. **Query labels**: + - Returned by the data source query. + - Example: `region`, `service`, `environment`. + +### Timing Options + +1. **Group wait**: Time before sending the first notification. +1. **Group interval**: Time between notifications for a group. +1. **Repeat interval**: Time before resending notifications for an unchanged group. + +Alerts sharing the **same label values** are grouped together, and timing options determine notification frequency. + +For more details, see: + +- [Grouping Alerts](https://grafana.com/docs/grafana/latest/alerting/fundamentals/notifications/group-alert-notifications/) +- [Alert Labels](https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/annotation-label/#label-types) + + + + +## A real-world example of alert grouping in action + +### Scenario: monitoring a distributed application + +You’re monitoring metrics like CPU usage, memory utilization, and network latency across multiple regions. Alert rules include labels such as `region: us-west` and `region: us-east`. If multiple alerts trigger across these regions, they can result in notification floods. + +### How to manage grouping + +To group alert rule notifications: + +1. **Define labels**: Use `region`, `metric`, or `instance` labels to categorize alerts. +1. **Configure Notification policies**: + - Group alerts by the `region` label. + - Example: + - Alerts for `region: us-west` go to the West Coast team. + - Alerts for `region: us-east` go to the East Coast team. + + + + +## Setting up alert rule grouping + +### Notification Policy + +[Notification policies](ref:notification-policies) group alert instances and route notifications to specific contact points. + +To follow the above example, we will create notification policies that route alert instances based on the `region` label to specific contact points. This setup ensures that alerts for a given region are consolidated into a single notification. Additionally, we will fine-tune the **timing settings** for each region by overriding the default parent policy, allowing more granular control over when notifications are sent. + + + +1. Sign in to Grafana: + + - **Grafana Cloud** users: Log in via Grafana Cloud. + - **OSS users**: Go to [http://localhost:3000](http://localhost:3000). + +1. Navigate to **Notification Policies**: + + - Go to **Alerts & IRM > Alerting > Notification Policies**. + +1. Add a child policy: + + - In the Default policy, click **+ New child policy**. + - **Label**: `region` + - **Operator**: `=` + - **Value**: `us-west` + + This label matches alert rules where the region label is us-west. + +1. Choose a **Contact point**: + + - Select **Webhook**. + + If you don’t have any contact points, add a [Contact point](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/#add-a-contact-point). + +1. Enable Continue matching: + + - Turn on **Continue matching subsequent sibling nodes** so the evaluation continues even after one or more labels (i.e. region label) match. + +1. Override grouping settings: + + - Toggle **Override grouping**. + - **Group by**: `region`. + + **Group by** consolidates alerts that share the same grouping label into a single notification. For example, all alerts with `region=us-west` will be combined into one notification, making it easier to manage and reducing alert fatigue. + +1. Set custom timing: + + - Toggle **Override general timings**. + - **Group interval**: `2m`. This ensures follow-up notifications for the same alert group will be sent at intervals of 2 minutes. While the default is 5 minutes, we chose 2 minutes here to provide faster feedback for demonstration purposes. + + **Timing options** control how often notifications are sent and can help balance timely alerting with minimizing noise. + +1. Save and repeat: + + - Repeat for `region = us-east` with a different webhook or a different contact point. + + {{< figure src="/media/docs/alerting/notificaiton-policies-region.png" max-width="750px" alt="Two nested notification policies to route and group alert notifications" >}} + + These nested policies should route alert instances where the region label is either us-west or us-east. + + {{< admonition type="note" >}} + In Grafana, each label within a notification policy must have a unique key. If you attempt to add the same label key (e.g., region) with different values (us-west and us-east), only the last entry is saved, and the previous one is discarded. This is because labels are stored as associative arrays (maps), where each key must be unique. + For identical label keys use regex matchers (e.g., _region=~"us-west|us-east"_). + {{}} + + + +{{< docs/ignore >}} + +1. Visit [http://localhost:3000](http://localhost:3000), where Grafana should be running +1. Navigate to **Alerts & IRM > Alerting > Notification policies**. +1. In the Default policy, click **+ New child policy**. + + - In the Default policy, click **+ New child policy**. + - **Label**: `region` + - **Operator**: `=` + - **Value**: `us-west` + + This label matches alert rules where the region label is us-west + +1. Choose a **Contact point**: + + - Select **Webhook**. + + If you don’t have any contact points, add a Contact point. + +1. Enable Continue matching: + + - Turn on **Continue matching subsequent sibling nodes** so the evaluation continues even after one or more labels (i.e. region label) match. + +1. Override grouping settings: + + - Toggle **Override grouping**. + - **Group by**: `region`. + + **Group by** consolidates alerts that share the same grouping label into a single notification. For example, all alerts with `region=us-west` will be combined into one notification, making it easier to manage and reducing alert fatigue. + +1. Set custom timing: + + - Toggle **Override general timings**. + - **Group interval**: `2m`. This ensures follow-up notifications for the same alert group will be sent at intervals of 2 minutes. While the default is 5 minutes, we chose 2 minutes here to provide faster feedback for demonstration purposes. + + **Timing options** control how often notifications are sent and can help balance timely alerting with minimizing noise. + +1. Save and repeat: + + - Repeat for `region = us-east` with a different webhook or a different contact point. + + **Note**: In Grafana, each label within a notification policy must have a unique key. If you attempt to add the same label key (e.g., region) with different values (us-west and us-east), only the last entry is saved, and the previous one is discarded. This is because labels are stored as associative arrays (maps), where each key must be unique. + For identical label keys use regex matchers (e.g., _region=~"us-west|us-east"_). + +{{< /docs/ignore >}} + + + + +## Create an alert rule + +In this section we configure an alert rule based on our application monitoring example. + +1. Navigate to **Alerting > Alert rules**. +2. Click **New alert rule**. + +### Enter an alert rule name + +Make it short and descriptive as this appears in your alert notification. For instance, `High CPU usage - Multi-region`. + +### Define query and alert condition + +In this section, we use the default options for Grafana-managed alert rule creation. The default options let us define the query, a expression (used to manipulate the data -- the `WHEN` field in the UI), and the condition that must be met for the alert to be triggered (in default mode is the threshold). + +Grafana includes a [test data source](https://grafana.com/docs/grafana/latest/datasources/testdata/) that creates simulated time series data. This data source is included in the demo environment for this tutorial. If you're working in Grafana Cloud or your own local Grafana instance, you can add the data source through the **Connections** menu. + +1. From the drop-down menu, select **TestData** data source. +1. From **Scenario** select **CSV Content**. +1. Copy in the following CSV data: + + - Select **TestData** as the data source. + - Set **Scenario** to **CSV Content**. + - Use the following CSV data: + + ```csv + region,cpu-usage,service,instance + us-west,35,web-server-1,server-01 + us-west,81,web-server-1,server-02 + us-east,79,web-server-2,server-03 + us-east,52,web-server-2,server-04 + us-west,45,db-server-1,server-05 + us-east,77,db-server-2,server-06 + us-west,82,db-server-1,server-07 + us-east,93,db-server-2,server-08 + ``` + + The returned data simulates a data source returning multiple time series, each leading to the creation of an alert instance for that specific time series. + +1. In the **Alert condition** section: + + - Keep `Last` as the value for the reducer function (`WHEN`), and `75` as the threshold value. This is the value above which the alert rule should trigger. + +1. Click **Preview alert rule condition** to run the queries. + + It should return 5 series in Firing state, two firing instances from the us-west region, and three from the us-east region. + + {{< figure src="/media/docs/alerting/regions-alert-instance-preview.png" max-width="750px" alt="Preview of a query returning alert instances." >}} + +### Set evaluation behavior + +Every alert rule is assigned to an evaluation group. You can assign the alert rule to an existing evaluation group or create a new one. + +1. In **Folder**, click **+ New folder** and enter a name. For example: `Multi-region CPU alerts`. This folder contains our alert rules. +1. In the **Evaluation group**, repeat the above step to create a new evaluation group. Name it `Multi-region CPU group`. +1. Choose an **Evaluation interval** (how often the alert are evaluated). Choose `1m`. + + The evaluation interval of 1 minute allows Grafana to detect changes quickly, while the longer **Group wait** (from our notification policy) and **Group interval** (inherited from the Default notification policy) allow for efficient grouping of alerts and minimize unnecessary notifications. + +1. Set the pending period to `0s` (zero seconds), so the alert rule fires the moment the condition is met (this minimizes the waiting time for the demonstration). + +### Configure labels and notifications + +Choose the notification policy where you want to receive your alert notifications. + +1. Select **Use notification policy**. +1. Click **Preview routing** to ensure correct matching. + + {{< figure src="/media/docs/alerting/region-notification-policy-routing-preview.png" max-width="750px" alt="Preview of alert instance routing with the region label matcher" >}} + + The preview shows that the region label from our data source is successfully matching the notification policies that we created earlier thanks to the label matcher that we configured. + +1. Click **Save rule and exit**. + + + + +## Receiving grouped alert notifications + +Now that the alert rule has been configured, you should receive alert notifications in the contact point whenever alerts trigger. + +When the configured alert rule detects CPU usage higher than 75% across multiple regions, it will evaluate the metric every minute. If the condition persists, notifications will be grouped together, with a **Group wait** of 30 seconds before the first alert is sent. Follow-up notifications are sent every 2 minutes for quick updates in this demonstration, but for reducing alert frequency, consider using the default or increasing the interval. If the condition continues for an extended period, a **Repeat interval** of 4 hours ensures that the alert is only resent if the issue persists + +As a result, our notification policy will route two notifications: one notification grouping the three alert instances from the `us-east` region and another grouping the two alert instances from the `us-west` region + +Grouped notifications example: + +Webhook - US East + +```json +{ + "receiver": "webhook-us-east", + "status": "firing", + "alerts": [{ "instance": "server-03" }, { "instance": "server-06" }, { "instance": "server-08" }] +} +``` + +Webhook - US West + +```json +{ + "receiver": "webhook-us-west", + "status": "firing", + "alerts": [{ "instance": "server-02" }, { "instance": "server-07" }] +} +``` + + + + + +## Conclusion + +Alert rule grouping simplifies incident management by consolidating related alerts. By configuring **notification policies** and using **labels** (such as _region_), you can group alerts based on specific criteria and route them to the appropriate teams. Fine-tuning **timing options**—including group wait, group interval, and repeat interval—further reduces noise and ensures notifications remain actionable without overwhelming on-call engineers. + + diff --git a/docs/sources/tutorials/alerting-get-started/index.md b/docs/sources/tutorials/alerting-get-started/index.md index 4b832c334e9..b26e8221c0a 100644 --- a/docs/sources/tutorials/alerting-get-started/index.md +++ b/docs/sources/tutorials/alerting-get-started/index.md @@ -11,10 +11,10 @@ labels: - cloud tags: - beginner -title: Get started with Grafana Alerting - Part 1 of 2 +title: Get started with Grafana Alerting - Part 1 of 3 weight: 50 killercoda: - title: Get started with Grafana Alerting - Part 1 of 2 + title: Get started with Grafana Alerting - Part 1 of 3 description: Get started with Grafana Alerting by creating your first alert rule, sending notifications to a webhook, and generating data to test it live — Part 1. backend: imageid: ubuntu @@ -22,7 +22,7 @@ killercoda: -# Get started with Grafana Alerting - Part 1 of 2 +# Get started with Grafana Alerting - Part 1 of 3 In this guide, we walk you through the process of setting up your first alert in just a few minutes. You'll witness your alert in action with real-time data, as well as sending alert notifications. From 6c5b4083391c7229941ee1d20bfe76811fdc3a22 Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Thu, 19 Dec 2024 13:28:20 +0100 Subject: [PATCH 32/32] Feature toggle: remove `dashboardSchemaV2` (#98252) --- .../configure-grafana/feature-toggles/index.md | 1 - packages/grafana-data/src/types/featureToggles.gen.ts | 1 - pkg/services/featuremgmt/registry.go | 7 ------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 3 ++- 6 files changed, 2 insertions(+), 15 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 5ce3a34a4e6..0ae6ead7728 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -220,7 +220,6 @@ Experimental features might be changed or removed without prior notice. | `timeRangeProvider` | Enables time pickers sync | | `prometheusUsesCombobox` | Use new combobox component for Prometheus query editor | | `userStorageAPI` | Enables the user storage API | -| `dashboardSchemaV2` | Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code. | | `playlistsWatcher` | Enables experimental watcher for playlists | | `prometheusSpecialCharsInLabelValues` | Adds support for quotes and special characters in label values for Prometheus queries | | `enableExtensionsAdminPage` | Enables the extension admin page regardless of development mode | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 7783296f507..22f9048e07d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -228,7 +228,6 @@ export interface FeatureToggles { userStorageAPI?: boolean; azureMonitorDisableLogLimit?: boolean; preinstallAutoUpdate?: boolean; - dashboardSchemaV2?: boolean; playlistsWatcher?: boolean; passwordlessMagicLinkAuthentication?: boolean; exploreMetricsRelatedLogs?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index ddde412a65e..47d55e87f6a 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1577,13 +1577,6 @@ var ( Owner: grafanaPluginsPlatformSquad, Expression: "true", // enabled by default }, - { - Name: "dashboardSchemaV2", - Description: "Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code.", - Stage: FeatureStageExperimental, - Owner: grafanaDashboardsSquad, - FrontendOnly: true, - }, { Name: "playlistsWatcher", Description: "Enables experimental watcher for playlists", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 92c6b16ce16..3322a206784 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -209,7 +209,6 @@ prometheusUsesCombobox,experimental,@grafana/observability-metrics,false,false,f userStorageAPI,experimental,@grafana/plugins-platform-backend,false,false,false azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false preinstallAutoUpdate,GA,@grafana/plugins-platform-backend,false,false,false -dashboardSchemaV2,experimental,@grafana/dashboards-squad,false,false,true playlistsWatcher,experimental,@grafana/grafana-app-platform-squad,false,true,false passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 72fb3c04379..5ee1eec83f6 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -847,10 +847,6 @@ const ( // Enables automatic updates for pre-installed plugins FlagPreinstallAutoUpdate = "preinstallAutoUpdate" - // FlagDashboardSchemaV2 - // Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code. - FlagDashboardSchemaV2 = "dashboardSchemaV2" - // FlagPlaylistsWatcher // Enables experimental watcher for playlists FlagPlaylistsWatcher = "playlistsWatcher" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 6827fab8a5f..ade8dd1367c 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1026,7 +1026,8 @@ "metadata": { "name": "dashboardSchemaV2", "resourceVersion": "1730192092473", - "creationTimestamp": "2024-10-29T08:54:52Z" + "creationTimestamp": "2024-10-29T08:54:52Z", + "deletionTimestamp": "2024-12-19T12:03:44Z" }, "spec": { "description": "Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code.",