From d231e271f2fcf13289b490d65bc402acb1385dd7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 08:31:38 +0000 Subject: [PATCH 01/29] Update dependency autoprefixer to v10.4.16 --- package.json | 2 +- yarn.lock | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 7ec3d9d5290..057902dc64e 100644 --- a/package.json +++ b/package.json @@ -154,7 +154,7 @@ "@types/yargs": "17.0.32", "@typescript-eslint/eslint-plugin": "6.18.1", "@typescript-eslint/parser": "6.18.1", - "autoprefixer": "10.4.14", + "autoprefixer": "10.4.16", "babel-jest": "29.7.0", "babel-loader": "9.1.3", "babel-plugin-angularjs-annotate": "0.10.0", diff --git a/yarn.lock b/yarn.lock index 805bf22d64d..59d4657c95f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10946,13 +10946,13 @@ __metadata: languageName: node linkType: hard -"autoprefixer@npm:10.4.14": - version: 10.4.14 - resolution: "autoprefixer@npm:10.4.14" +"autoprefixer@npm:10.4.16": + version: 10.4.16 + resolution: "autoprefixer@npm:10.4.16" dependencies: - browserslist: "npm:^4.21.5" - caniuse-lite: "npm:^1.0.30001464" - fraction.js: "npm:^4.2.0" + browserslist: "npm:^4.21.10" + caniuse-lite: "npm:^1.0.30001538" + fraction.js: "npm:^4.3.6" normalize-range: "npm:^0.1.2" picocolors: "npm:^1.0.0" postcss-value-parser: "npm:^4.2.0" @@ -10960,7 +10960,7 @@ __metadata: postcss: ^8.1.0 bin: autoprefixer: bin/autoprefixer - checksum: 9cee5c32557611520aadf3f0caacccaf37fe845e5e8f042a0e1321a235725b537edc01e5897206fd68322997430fdd38fe63f52a1c926d64f4a5514ee5acab81 + checksum: 3514a4ae63f1f55006c96eb93acef4a0284d78b640d8f27d3178d40b302576e346619001ca139b4ddc5e7b0c5e66921aa45d8e3752d8d521598119aab8ff4997 languageName: node linkType: hard @@ -11532,7 +11532,7 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.0.0, browserslist@npm:^4.14.5, browserslist@npm:^4.21.4, browserslist@npm:^4.21.5, browserslist@npm:^4.22.2": +"browserslist@npm:^4.0.0, browserslist@npm:^4.14.5, browserslist@npm:^4.21.10, browserslist@npm:^4.21.4, browserslist@npm:^4.22.2": version: 4.22.2 resolution: "browserslist@npm:4.22.2" dependencies: @@ -11839,7 +11839,7 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001464, caniuse-lite@npm:^1.0.30001565": +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001538, caniuse-lite@npm:^1.0.30001565": version: 1.0.30001576 resolution: "caniuse-lite@npm:1.0.30001576" checksum: 51632942733593f310e581bd91c9558b8d75fbf67160a39f8036d2976cd7df9183e96d4c9d9e6f18e0205950b940d9c761bcfb7810962d7899f8a1179fde6e3f @@ -16246,10 +16246,10 @@ __metadata: languageName: node linkType: hard -"fraction.js@npm:^4.2.0": - version: 4.2.0 - resolution: "fraction.js@npm:4.2.0" - checksum: 8f8e3c02a4d10cd03bae5c036c02ef0bd1a50be69ac56e5b9b25025ff07466c1d2288f383fb613ecec583e77bcfd586dee2d932f40e588c910bf55c5103014ab +"fraction.js@npm:^4.3.6": + version: 4.3.7 + resolution: "fraction.js@npm:4.3.7" + checksum: bb5ebcdeeffcdc37b68ead3bdfc244e68de188e0c64e9702197333c72963b95cc798883ad16adc21588088b942bca5b6a6ff4aeb1362d19f6f3b629035dc15f5 languageName: node linkType: hard @@ -17168,7 +17168,7 @@ __metadata: angular-sanitize: "npm:1.8.3" ansicolor: "npm:1.1.100" app: "link:./public/app" - autoprefixer: "npm:10.4.14" + autoprefixer: "npm:10.4.16" babel-jest: "npm:29.7.0" babel-loader: "npm:9.1.3" babel-plugin-angularjs-annotate: "npm:0.10.0" From 21200fd5f84966353aeebf23d1bc00525f5056a4 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 15 Jan 2024 09:43:53 +0000 Subject: [PATCH 02/29] Chore: Mock out faro so this test doesn't throw an error (#80477) mock out faro for all tests --- .../GrafanaJavascriptAgentBackend.test.ts | 81 ++++++++++--------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.test.ts b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.test.ts index dfb6d424324..e1f19cd6a85 100644 --- a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.test.ts +++ b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.test.ts @@ -1,16 +1,50 @@ import { BuildInfo } from '@grafana/data'; import { GrafanaEdition } from '@grafana/data/src/types/config'; -import { Instrumentation } from '@grafana/faro-core'; +import { Faro, Instrumentation } from '@grafana/faro-core'; import * as faroWebSdkModule from '@grafana/faro-web-sdk'; -import { FetchTransport, initializeFaro } from '@grafana/faro-web-sdk'; +import { BrowserConfig, FetchTransport } from '@grafana/faro-web-sdk'; import { EchoSrvTransport } from './EchoSrvTransport'; import { GrafanaJavascriptAgentBackend, GrafanaJavascriptAgentBackendOptions } from './GrafanaJavascriptAgentBackend'; describe('GrafanaJavascriptAgentEchoBackend', () => { + let mockedSetUser: jest.Mock; + let initializeFaroMock: jest.SpyInstance; + beforeEach(() => { + // arrange + mockedSetUser = jest.fn(); + const mockedInstrumentationsForConfig: Instrumentation[] = []; + const mockedInstrumentations = { + add: jest.fn(), + instrumentations: mockedInstrumentationsForConfig, + remove: jest.fn(), + }; + const mockedInternalLogger = { + prefix: 'Faro', + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + initializeFaroMock = jest.spyOn(faroWebSdkModule, 'initializeFaro').mockReturnValueOnce({ + ...faroWebSdkModule.faro, + api: { + ...faroWebSdkModule.faro.api, + setUser: mockedSetUser, + }, + config: { + ...faroWebSdkModule.faro.config, + instrumentations: mockedInstrumentationsForConfig, + }, + instrumentations: mockedInstrumentations, + internalLogger: mockedInternalLogger, + }); + }); + + afterEach(() => { jest.resetAllMocks(); - window.fetch = jest.fn(); jest.resetModules(); jest.clearAllMocks(); }); @@ -50,47 +84,18 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { //assert expect(constructorSpy).toHaveBeenCalledTimes(1); - expect(faroWebSdkModule.faro.transports.transports.length).toEqual(2); - expect(faroWebSdkModule.faro.transports.transports[0]).toBeInstanceOf(EchoSrvTransport); - expect(faroWebSdkModule.faro.transports.transports[1]).toBeInstanceOf(FetchTransport); + expect(initializeFaroMock).toHaveBeenCalledTimes(1); + expect(initializeFaroMock.mock.calls[0][0].transports?.length).toEqual(2); + expect(initializeFaroMock.mock.calls[0][0].transports?.[0]).toBeInstanceOf(EchoSrvTransport); + expect(initializeFaroMock.mock.calls[0][0].transports?.[1]).toBeInstanceOf(FetchTransport); }); - it('will initialize GrafanaJavascriptAgent and set user', () => { - // arrange - const mockedSetUser = jest.fn(); - const mockedInstrumentationsForConfig: Instrumentation[] = []; - const mockedInstrumentations = { - add: jest.fn(), - instrumentations: mockedInstrumentationsForConfig, - remove: jest.fn(), - }; - const mockedInternalLogger = { - prefix: 'Faro', - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; - - jest.spyOn(faroWebSdkModule, 'initializeFaro').mockReturnValueOnce({ - ...faroWebSdkModule.faro, - api: { - ...faroWebSdkModule.faro.api, - setUser: mockedSetUser, - }, - config: { - ...faroWebSdkModule.faro.config, - instrumentations: mockedInstrumentationsForConfig, - }, - instrumentations: mockedInstrumentations, - internalLogger: mockedInternalLogger, - }); - + it('will initialize GrafanaJavascriptAgent and set user', async () => { //act new GrafanaJavascriptAgentBackend(options); //assert - expect(initializeFaro).toHaveBeenCalledTimes(1); + expect(initializeFaroMock).toHaveBeenCalledTimes(1); expect(mockedSetUser).toHaveBeenCalledTimes(1); expect(mockedSetUser).toHaveBeenCalledWith({ id: '504', From 1596339796b12b47f9781f1b60c49d6e3d5b4ea2 Mon Sep 17 00:00:00 2001 From: Kyle Cunningham Date: Mon, 15 Jan 2024 03:53:22 -0600 Subject: [PATCH 03/29] Chore: Update CODEOWNERS for end of BI (#80522) Update CODEOWNERS --- .github/CODEOWNERS | 38 +++++++++++++-------------- pkg/kindsysreport/codegen/report.json | 4 +-- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9acf24fa6e8..0410d4059d1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -185,13 +185,13 @@ /devenv/docker/blocks/influxdb1/ @grafana/observability-metrics /devenv/docker/blocks/jaeger/ @grafana/observability-traces-and-profiling /devenv/docker/blocks/maildev/ @grafana/alerting-frontend -/devenv/docker/blocks/mariadb/ @grafana/grafana-bi-squad +/devenv/docker/blocks/mariadb/ @grafana/oss-big-tent /devenv/docker/blocks/memcached/ @grafana/backend-platform /devenv/docker/blocks/mimir_backend/ @grafana/alerting-backend-product -/devenv/docker/blocks/mssql/ @grafana/grafana-bi-squad -/devenv/docker/blocks/mssql_arm64/ @grafana/grafana-bi-squad -/devenv/docker/blocks/mssql_tests/ @grafana/grafana-bi-squad -/devenv/docker/blocks/mssql_tls/ @grafana/grafana-bi-squad +/devenv/docker/blocks/mssql/ @grafana/partner-datasources +/devenv/docker/blocks/mssql_arm64/ @grafana/partner-datasources +/devenv/docker/blocks/mssql_tests/ @grafana/partner-datasources +/devenv/docker/blocks/mssql_tls/ @grafana/partner-datasources /devenv/docker/blocks/mysql/ @grafana/oss-big-tent /devenv/docker/blocks/mysql_exporter/ @grafana/oss-big-tent /devenv/docker/blocks/mysql_opendata/ @grafana/oss-big-tent @@ -317,8 +317,8 @@ /packages/grafana-ui/.storybook/ @grafana/plugins-platform-frontend /packages/grafana-ui/src/components/ @grafana/grafana-frontend-platform /packages/grafana-ui/src/components/DateTimePickers/ @grafana/grafana-frontend-platform -/packages/grafana-ui/src/components/Table/ @grafana/grafana-bi-squad -/packages/grafana-ui/src/components/Table/SparklineCell.tsx @grafana/grafana-bi-squad @grafana/app-o11y-visualizations +/packages/grafana-ui/src/components/Table/ @grafana/dataviz-squad +/packages/grafana-ui/src/components/Table/SparklineCell.tsx @grafana/dataviz-squad @grafana/app-o11y-visualizations /packages/grafana-ui/src/components/Gauge/ @grafana/dataviz-squad /packages/grafana-ui/src/components/BarGauge/ @grafana/dataviz-squad /packages/grafana-ui/src/components/uPlot/ @grafana/dataviz-squad @@ -333,7 +333,7 @@ /packages/grafana-ui/src/graveyard/GraphNG/ @grafana/dataviz-squad /packages/grafana-ui/src/graveyard/TimeSeries/ @grafana/dataviz-squad /packages/grafana-ui/src/utils/storybook/ @grafana/plugins-platform-frontend -/packages/grafana-data/src/transformations/ @grafana/grafana-bi-squad +/packages/grafana-data/src/transformations/ @grafana/dataviz-squad /packages/grafana-data/src/**/*logs* @grafana/observability-logs /packages/grafana-schema/src/**/*tempo* @grafana/observability-traces-and-profiling /packages/grafana-schema/src/**/*canvas* @grafana/dataviz-squad @@ -392,11 +392,11 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/features/connections/ @grafana/plugins-platform-frontend @mikkancso /public/app/features/correlations/ @grafana/explore-squad /public/app/features/dashboard/ @grafana/dashboards-squad -/public/app/features/dashboard/components/TransformationsEditor/ @grafana/grafana-bi-squad +/public/app/features/dashboard/components/TransformationsEditor/ @grafana/dataviz-squad /public/app/features/dashboard-scene/ @grafana/dashboards-squad /public/app/features/datasources/ @grafana/plugins-platform-frontend @mikkancso /public/app/features/dimensions/ @grafana/dataviz-squad -/public/app/features/dataframe-import/ @grafana/grafana-bi-squad +/public/app/features/dataframe-import/ @grafana/dataviz-squad /public/app/features/explore/ @grafana/explore-squad /public/app/features/expressions/ @grafana/observability-metrics /public/app/features/folders/ @grafana/grafana-frontend-platform @@ -423,8 +423,8 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/features/teams/ @grafana/identity-access-team /public/app/features/templating/ @grafana/dashboards-squad /public/app/features/trails/ @torkelo -/public/app/features/transformers/ @grafana/grafana-bi-squad -/public/app/features/transformers/timeSeriesTable/ @grafana/grafana-bi-squad @grafana/app-o11y-visualizations +/public/app/features/transformers/ @grafana/dataviz-squad +/public/app/features/transformers/timeSeriesTable/ @grafana/dataviz-squad @grafana/app-o11y-visualizations /public/app/features/users/ @grafana/identity-access-team /public/app/features/variables/ @grafana/dashboards-squad /public/app/plugins/panel/alertGroups/ @grafana/alerting-frontend @@ -434,7 +434,7 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/plugins/panel/bargauge/ @grafana/dataviz-squad /public/app/plugins/panel/dashlist/ @grafana/grafana-frontend-platform /public/app/plugins/panel/debug/ @ryantxu -/public/app/plugins/panel/datagrid/ @grafana/grafana-bi-squad +/public/app/plugins/panel/datagrid/ @grafana/dataviz-squad /public/app/plugins/panel/gauge/ @grafana/dataviz-squad /public/app/plugins/panel/gettingstarted/ @grafana/grafana-frontend-platform /public/app/plugins/panel/graph/ @grafana/dataviz-squad @@ -447,9 +447,9 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/plugins/panel/piechart/ @grafana/dataviz-squad /public/app/plugins/panel/state-timeline/ @grafana/dataviz-squad /public/app/plugins/panel/status-history/ @grafana/dataviz-squad -/public/app/plugins/panel/table/ @grafana/grafana-bi-squad -/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx @grafana/grafana-bi-squad @grafana/app-o11y-visualizations -/public/app/plugins/panel/table-old/ @grafana/grafana-bi-squad +/public/app/plugins/panel/table/ @grafana/dataviz-squad +/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx @grafana/dataviz-squad @grafana/app-o11y-visualizations +/public/app/plugins/panel/table-old/ @grafana/dataviz-squad /public/app/plugins/panel/timeseries/ @grafana/dataviz-squad /public/app/plugins/panel/trend/ @grafana/dataviz-squad /public/app/plugins/panel/geomap/ @grafana/dataviz-squad @@ -508,7 +508,7 @@ cypress.config.js @grafana/grafana-frontend-platform /scripts/circle-* @grafana/grafana-release-guild /scripts/publish-npm-packages.sh @grafana/grafana-release-guild @grafana/plugins-platform-frontend /scripts/validate-npm-packages.sh @grafana/grafana-release-guild @grafana/plugins-platform-frontend -/scripts/ci-frontend-metrics.sh @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend @grafana/grafana-bi-squad +/scripts/ci-frontend-metrics.sh @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend @grafana/dataviz-squad /scripts/cli/ @grafana/grafana-frontend-platform /scripts/clean-git-or-error.sh @grafana/grafana-as-code /scripts/grafana-server/ @grafana/grafana-frontend-platform @@ -528,7 +528,7 @@ cypress.config.js @grafana/grafana-frontend-platform /scripts/generate-icon-bundle.js @grafana/plugins-platform-frontend @grafana/grafana-frontend-platform /scripts/levitate-parse-json-report.js @grafana/plugins-platform-frontend -/scripts/docs/generate-transformations.ts @grafana/grafana-bi-squad +/scripts/docs/generate-transformations.ts @grafana/dataviz-squad /scripts/webpack/ @grafana/frontend-ops /scripts/generate-a11y-report.sh @grafana/grafana-frontend-platform .pa11yci.conf.js @grafana/grafana-frontend-platform @@ -554,7 +554,7 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/plugins/datasource/jaeger/ @grafana/observability-traces-and-profiling /public/app/plugins/datasource/loki/ @grafana/observability-logs /public/app/plugins/datasource/mixed/ @grafana/dashboards-squad -/public/app/plugins/datasource/mssql/ @grafana/grafana-bi-squad +/public/app/plugins/datasource/mssql/ @grafana/partner-datasources /public/app/plugins/datasource/mysql/ @grafana/oss-big-tent /public/app/plugins/datasource/opentsdb/ @grafana/observability-metrics /public/app/plugins/datasource/grafana-postgresql-datasource/ @grafana/oss-big-tent diff --git a/pkg/kindsysreport/codegen/report.json b/pkg/kindsysreport/codegen/report.json index 70ce9698f48..015db1369bd 100644 --- a/pkg/kindsysreport/codegen/report.json +++ b/pkg/kindsysreport/codegen/report.json @@ -459,7 +459,7 @@ "datagridpanelcfg": { "category": "composable", "codeowners": [ - "grafana/grafana-bi-squad" + "grafana/dataviz-squad" ], "currentVersion": [ 0, @@ -1731,7 +1731,7 @@ "tablepanelcfg": { "category": "composable", "codeowners": [ - "grafana/grafana-bi-squad" + "grafana/dataviz-squad" ], "currentVersion": [ 0, From 702e7cb3ac3d5c028bfe4d2f9e69337437b72571 Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Mon, 15 Jan 2024 09:54:52 +0000 Subject: [PATCH 04/29] Azure: Fix type exports (#80469) Fix type exports --- public/app/plugins/datasource/azuremonitor/types/query.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/plugins/datasource/azuremonitor/types/query.ts b/public/app/plugins/datasource/azuremonitor/types/query.ts index 0c4287928cd..c9098ef91c7 100644 --- a/public/app/plugins/datasource/azuremonitor/types/query.ts +++ b/public/app/plugins/datasource/azuremonitor/types/query.ts @@ -1,7 +1,7 @@ import { AzureMonitorQuery as AzureMonitorQueryBase, AzureQueryType } from '../dataquery.gen'; export { AzureQueryType }; -export { +export type { AzureMetricQuery, AzureLogsQuery, AzureResourceGraphQuery, @@ -9,8 +9,8 @@ export { AzureMonitorResource, AzureMetricDimension, AzureTracesFilter, - ResultFormat, } from '../dataquery.gen'; +export { ResultFormat } from '../dataquery.gen'; /** * Represents the query as it moves through the frontend query editor and datasource files. From 9b5b76aedd9aa0fb04722587f9930792f0aef0a6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 10:02:01 +0000 Subject: [PATCH 05/29] Update dependency @types/slate-plain-serializer to v0.7.5 (#80495) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 057902dc64e..54b0079d1ce 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "@types/redux-mock-store": "1.0.6", "@types/semver": "7.5.6", "@types/slate": "0.47.11", - "@types/slate-plain-serializer": "0.7.2", + "@types/slate-plain-serializer": "0.7.5", "@types/slate-react": "0.22.9", "@types/systemjs": "6.13.1", "@types/testing-library__jest-dom": "5.14.9", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 8fc8d1b184c..33d4da803fe 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -157,7 +157,7 @@ "@types/react-transition-group": "4.4.10", "@types/react-window": "1.8.5", "@types/slate": "0.47.11", - "@types/slate-plain-serializer": "0.7.2", + "@types/slate-plain-serializer": "0.7.5", "@types/slate-react": "0.22.9", "@types/testing-library__jest-dom": "5.14.9", "@types/tinycolor2": "1.4.6", diff --git a/yarn.lock b/yarn.lock index 59d4657c95f..8817cd6b439 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3567,7 +3567,7 @@ __metadata: "@types/react-transition-group": "npm:4.4.10" "@types/react-window": "npm:1.8.5" "@types/slate": "npm:0.47.11" - "@types/slate-plain-serializer": "npm:0.7.2" + "@types/slate-plain-serializer": "npm:0.7.5" "@types/slate-react": "npm:0.22.9" "@types/testing-library__jest-dom": "npm:5.14.9" "@types/tinycolor2": "npm:1.4.6" @@ -9249,12 +9249,12 @@ __metadata: languageName: node linkType: hard -"@types/slate-plain-serializer@npm:0.7.2": - version: 0.7.2 - resolution: "@types/slate-plain-serializer@npm:0.7.2" +"@types/slate-plain-serializer@npm:0.7.5": + version: 0.7.5 + resolution: "@types/slate-plain-serializer@npm:0.7.5" dependencies: "@types/slate": "npm:*" - checksum: b982c2241012e547eeba18e1104f2baa4ba0dbb674e4dcec9bf78bfe039fbde1599bd7a8c1d398baec67401a0949416c7543c0133ab80f81cddfdaff916c3f59 + checksum: 8e8e7096f7656c51839a1d93b051cac7e5f106da682ccd5bd2eaba2e5364681471fd97a207261e232704f76ef24891728fbed015a9a801a220576255e9a46202 languageName: node linkType: hard @@ -17143,7 +17143,7 @@ __metadata: "@types/redux-mock-store": "npm:1.0.6" "@types/semver": "npm:7.5.6" "@types/slate": "npm:0.47.11" - "@types/slate-plain-serializer": "npm:0.7.2" + "@types/slate-plain-serializer": "npm:0.7.5" "@types/slate-react": "npm:0.22.9" "@types/systemjs": "npm:6.13.1" "@types/testing-library__jest-dom": "npm:5.14.9" From f862bcb509e825f78bbb1bd3f4811cad1ffdc14e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 09:48:54 +0000 Subject: [PATCH 06/29] Update dependency csstype to v3.1.3 --- packages/grafana-ui/package.json | 2 +- yarn.lock | 17 +++++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 33d4da803fe..4e37b43ecd2 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -165,7 +165,7 @@ "common-tags": "1.8.2", "core-js": "3.33.0", "css-loader": "6.8.1", - "csstype": "3.1.2", + "csstype": "3.1.3", "esbuild": "0.18.12", "expose-loader": "4.1.0", "mock-raf": "1.0.1", diff --git a/yarn.lock b/yarn.lock index 8817cd6b439..1bb7f27243f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3578,7 +3578,7 @@ __metadata: common-tags: "npm:1.8.2" core-js: "npm:3.33.0" css-loader: "npm:6.8.1" - csstype: "npm:3.1.2" + csstype: "npm:3.1.3" d3: "npm:7.8.5" date-fns: "npm:2.30.0" esbuild: "npm:0.18.12" @@ -13216,10 +13216,10 @@ __metadata: languageName: node linkType: hard -"csstype@npm:3.1.2": - version: 3.1.2 - resolution: "csstype@npm:3.1.2" - checksum: 1f39c541e9acd9562996d88bc9fb62d1cb234786ef11ed275567d4b2bd82e1ceacde25debc8de3d3b4871ae02c2933fa02614004c97190711caebad6347debc2 +"csstype@npm:3.1.3, csstype@npm:^3.0.2, csstype@npm:^3.1.2": + version: 3.1.3 + resolution: "csstype@npm:3.1.3" + checksum: f593cce41ff5ade23f44e77521e3a1bcc2c64107041e1bf6c3c32adc5187d0d60983292fda326154d20b01079e24931aa5b08e4467cc488b60bb1e7f6d478ade languageName: node linkType: hard @@ -13230,13 +13230,6 @@ __metadata: languageName: node linkType: hard -"csstype@npm:^3.0.2, csstype@npm:^3.1.2": - version: 3.1.3 - resolution: "csstype@npm:3.1.3" - checksum: f593cce41ff5ade23f44e77521e3a1bcc2c64107041e1bf6c3c32adc5187d0d60983292fda326154d20b01079e24931aa5b08e4467cc488b60bb1e7f6d478ade - languageName: node - linkType: hard - "cypress-file-upload@npm:5.0.8": version: 5.0.8 resolution: "cypress-file-upload@npm:5.0.8" From 083605c17f47d6eea81990981e28192faa8feb1c Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 15 Jan 2024 10:12:41 +0000 Subject: [PATCH 07/29] DockedMenu: only set the menu state to be `docked` if the window size is big enough (#80379) * only set the menu docked in state if the window size is big enough * adjust sizes * remove top border on dockedMegaMenu * another css tweak * use xxl consistently * CONSISTENTLY * ok maybe not --- public/app/core/components/AppChrome/AppChrome.tsx | 1 - public/app/core/components/AppChrome/AppChromeMenu.tsx | 2 +- public/app/core/components/AppChrome/AppChromeService.tsx | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index 978d1db1678..b231e7d7191 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -131,7 +131,6 @@ const getStyles = (theme: GrafanaTheme2) => { dockedMegaMenu: css({ background: theme.colors.background.primary, borderRight: `1px solid ${theme.colors.border.weak}`, - borderTop: `1px solid ${theme.colors.border.weak}`, display: 'none', zIndex: theme.zIndex.navbarFixed, diff --git a/public/app/core/components/AppChrome/AppChromeMenu.tsx b/public/app/core/components/AppChrome/AppChromeMenu.tsx index 96ee20c3aca..5efec4390df 100644 --- a/public/app/core/components/AppChrome/AppChromeMenu.tsx +++ b/public/app/core/components/AppChrome/AppChromeMenu.tsx @@ -76,7 +76,7 @@ export function AppChromeMenu({}: Props) { } const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { - const topPosition = (searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2) + 1; + const topPosition = searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2; return { backdrop: css({ diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index d8d57782084..3cf837cc893 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -33,6 +33,7 @@ export class AppChromeService { private megaMenuDocked = Boolean( config.featureToggles.dockedMegaMenu && + window.innerWidth >= config.theme2.breakpoints.values.xl && store.getBool( DOCKED_LOCAL_STORAGE_KEY, Boolean(config.featureToggles.dockedMegaMenu && window.innerWidth >= config.theme2.breakpoints.values.xxl) From 20db9ebcc24167165fdbc73cd2a6b925c3b776c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 11:37:57 +0100 Subject: [PATCH 08/29] Bump go.opentelemetry.io/collector/pdata from 1.0.0-rc8 to 1.0.1 (#80520) Bumps [go.opentelemetry.io/collector/pdata](https://github.com/open-telemetry/opentelemetry-collector) from 1.0.0-rc8 to 1.0.1. - [Release notes](https://github.com/open-telemetry/opentelemetry-collector/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-collector/blob/main/CHANGELOG-API.md) - [Commits](https://github.com/open-telemetry/opentelemetry-collector/compare/pdata/v1.0.0-rc8...pdata/v1.0.1) --- updated-dependencies: - dependency-name: go.opentelemetry.io/collector/pdata dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 66135765846..52774e82251 100644 --- a/go.mod +++ b/go.mod @@ -100,7 +100,7 @@ require ( github.com/vectordotdev/go-datemath v0.1.1-0.20220323213446-f3954d0b18ae // @grafana/backend-platform github.com/yalue/merged_fs v1.2.2 // @grafana/grafana-as-code github.com/yudai/gojsondiff v1.0.0 // @grafana/backend-platform - go.opentelemetry.io/collector/pdata v1.0.0-rc8 // @grafana/backend-platform + go.opentelemetry.io/collector/pdata v1.0.1 // @grafana/backend-platform go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // @grafana/grafana-operator-experience-squad go.opentelemetry.io/otel/exporters/jaeger v1.10.0 // @grafana/backend-platform go.opentelemetry.io/otel/sdk v1.21.0 // @grafana/backend-platform diff --git a/go.sum b/go.sum index 86c4c87236d..fcf44bbfe21 100644 --- a/go.sum +++ b/go.sum @@ -2997,8 +2997,8 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/collector/pdata v1.0.0-rc8 h1:vBikWdZFsRiT5dVsLQhnE99w3edM7eem3Q9dSqMlStE= -go.opentelemetry.io/collector/pdata v1.0.0-rc8/go.mod h1:BVCBhWgclYCh7Oi6BkMiQfRa6MXv1uRTlKXuL5oBby8= +go.opentelemetry.io/collector/pdata v1.0.1 h1:dGX2h7maA6zHbl5D3AsMnF1c3Nn+3EUftbVCLzeyNvA= +go.opentelemetry.io/collector/pdata v1.0.1/go.mod h1:jutXeu0QOXYY8wcZ/hege+YAnSBP3+jpTqYU1+JTI5Y= go.opentelemetry.io/contrib v0.18.0/go.mod h1:G/EtFaa6qaN7+LxqfIAT3GiZa7Wv5DTBUzl5H4LY0Kc= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 h1:SpGay3w+nEwMpfVnbqOLH5gY52/foP8RE8UzTZ1pdSE= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1/go.mod h1:4UoMYEZOC0yN/sPGH76KPkkU7zgiEWYWL9vwmbnTJPE= From 97f71c2240735fec1dc6706c9cfc6a5a4ba2e714 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 10:09:52 +0000 Subject: [PATCH 09/29] Update dependency eslint-plugin-jest to v27.6.3 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 54b0079d1ce..b184493ffc1 100644 --- a/package.json +++ b/package.json @@ -176,7 +176,7 @@ "eslint": "8.52.0", "eslint-config-prettier": "8.8.0", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jest": "27.6.0", + "eslint-plugin-jest": "27.6.3", "eslint-plugin-jsdoc": "46.8.2", "eslint-plugin-jsx-a11y": "6.7.1", "eslint-plugin-lodash": "7.4.0", diff --git a/yarn.lock b/yarn.lock index 1bb7f27243f..c6f9d115bd1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15180,9 +15180,9 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-jest@npm:27.6.0": - version: 27.6.0 - resolution: "eslint-plugin-jest@npm:27.6.0" +"eslint-plugin-jest@npm:27.6.3": + version: 27.6.3 + resolution: "eslint-plugin-jest@npm:27.6.3" dependencies: "@typescript-eslint/utils": "npm:^5.10.0" peerDependencies: @@ -15194,7 +15194,7 @@ __metadata: optional: true jest: optional: true - checksum: e01ff002d55fa09624c53a4b984e80175e75da671bc9b48cc2909b134fe6df25c8143798359bb5991e77e4ffb5b7d69c1c2340e8f661d080b7938a9e5e077122 + checksum: a29319d5a090633e30658da7521f9fa0468f861403509538e0927175ab2a46a7f60bd7fd344b54f7fb5f8e8b6385d8db67c305502a4de1291f32191163e74725 languageName: node linkType: hard @@ -17199,7 +17199,7 @@ __metadata: eslint: "npm:8.52.0" eslint-config-prettier: "npm:8.8.0" eslint-plugin-import: "npm:^2.26.0" - eslint-plugin-jest: "npm:27.6.0" + eslint-plugin-jest: "npm:27.6.3" eslint-plugin-jsdoc: "npm:46.8.2" eslint-plugin-jsx-a11y: "npm:6.7.1" eslint-plugin-lodash: "npm:7.4.0" From b7d6fa442305badcbb59f99e6f037234250c38eb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 10:10:37 +0000 Subject: [PATCH 10/29] Update dependency glob to v10.3.10 --- package.json | 2 +- packages/grafana-plugin-configs/package.json | 2 +- yarn.lock | 33 ++++++-------------- 3 files changed, 11 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index b184493ffc1..e07c69f7d97 100644 --- a/package.json +++ b/package.json @@ -185,7 +185,7 @@ "eslint-webpack-plugin": "4.0.1", "expose-loader": "4.1.0", "fork-ts-checker-webpack-plugin": "8.0.0", - "glob": "10.3.3", + "glob": "10.3.10", "html-loader": "4.2.0", "html-webpack-plugin": "5.5.3", "http-server": "14.1.1", diff --git a/packages/grafana-plugin-configs/package.json b/packages/grafana-plugin-configs/package.json index 4c3f686a3f1..bd2e7d03191 100644 --- a/packages/grafana-plugin-configs/package.json +++ b/packages/grafana-plugin-configs/package.json @@ -11,7 +11,7 @@ "copy-webpack-plugin": "11.0.0", "eslint-webpack-plugin": "4.0.1", "fork-ts-checker-webpack-plugin": "8.0.0", - "glob": "10.3.3", + "glob": "10.3.10", "replace-in-file-webpack-plugin": "1.0.6", "swc-loader": "0.2.3", "webpack": "5.89.0" diff --git a/yarn.lock b/yarn.lock index c6f9d115bd1..b019b3a0424 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3398,7 +3398,7 @@ __metadata: copy-webpack-plugin: "npm:11.0.0" eslint-webpack-plugin: "npm:4.0.1" fork-ts-checker-webpack-plugin: "npm:8.0.0" - glob: "npm:10.3.3" + glob: "npm:10.3.10" replace-in-file-webpack-plugin: "npm:1.0.6" swc-loader: "npm:0.2.3" tslib: "npm:2.6.0" @@ -16783,18 +16783,18 @@ __metadata: languageName: node linkType: hard -"glob@npm:10.3.3": - version: 10.3.3 - resolution: "glob@npm:10.3.3" +"glob@npm:10.3.10, glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.2.5, glob@npm:^10.2.7": + version: 10.3.10 + resolution: "glob@npm:10.3.10" dependencies: foreground-child: "npm:^3.1.0" - jackspeak: "npm:^2.0.3" + jackspeak: "npm:^2.3.5" minimatch: "npm:^9.0.1" minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry: "npm:^1.10.1" bin: - glob: dist/cjs/src/bin.js - checksum: 0d1a59dff5d5d7085f9c1e3b0c9c3a7e3a199a013ef8f800c0886e3cfe6f8e293f7847081021a97f96616bf778c053c6937382675f369ec8231c8b95d3ba11e2 + glob: dist/esm/bin.mjs + checksum: 38bdb2c9ce75eb5ed168f309d4ed05b0798f640b637034800a6bf306f39d35409bf278b0eaaffaec07591085d3acb7184a201eae791468f0f617771c2486a6a8 languageName: node linkType: hard @@ -16826,21 +16826,6 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.2.5, glob@npm:^10.2.7": - version: 10.3.10 - resolution: "glob@npm:10.3.10" - dependencies: - foreground-child: "npm:^3.1.0" - jackspeak: "npm:^2.3.5" - minimatch: "npm:^9.0.1" - minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" - path-scurry: "npm:^1.10.1" - bin: - glob: dist/esm/bin.mjs - checksum: 38bdb2c9ce75eb5ed168f309d4ed05b0798f640b637034800a6bf306f39d35409bf278b0eaaffaec07591085d3acb7184a201eae791468f0f617771c2486a6a8 - languageName: node - linkType: hard - "glob@npm:^7.1.1, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -17213,7 +17198,7 @@ __metadata: file-saver: "npm:2.0.5" fork-ts-checker-webpack-plugin: "npm:8.0.0" framework-utils: "npm:^1.1.0" - glob: "npm:10.3.3" + glob: "npm:10.3.10" history: "npm:4.10.1" hoist-non-react-statics: "npm:3.3.2" html-loader: "npm:4.2.0" @@ -19178,7 +19163,7 @@ __metadata: languageName: node linkType: hard -"jackspeak@npm:^2.0.3, jackspeak@npm:^2.3.5": +"jackspeak@npm:^2.3.5": version: 2.3.6 resolution: "jackspeak@npm:2.3.6" dependencies: From 94ec6474d372c73e09a4782c1ebf0a7773c9d8aa Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Mon, 15 Jan 2024 10:56:31 +0000 Subject: [PATCH 11/29] NestedFolders: Support Shared with me folder for showing items you've been granted access to (#80141) * start shared with me frontend tweaks * prevent linking to sharedwithme folder * tests * make divider take up 0 height * Prevent sharedwithme from being selected * test git push * pr feedback * prevent setting url for sharedwithme * split iconForItem/kind functions * Hide sharedwithme in nested folder picker * fix test fixture --- packages/grafana-runtime/src/config.ts | 1 + .../NestedFolderPicker/NestedFolderPicker.tsx | 7 +- .../api/browseDashboardsAPI.ts | 2 + .../browse-dashboards/api/services.ts | 3 +- .../components/CheckboxCell.tsx | 16 +++- .../components/DashboardsTree.test.tsx | 76 ++++++++++++++++++- .../components/DashboardsTree.tsx | 48 ++++++++++-- .../browse-dashboards/components/NameCell.tsx | 4 +- .../browse-dashboards/components/utils.ts | 6 ++ .../fixtures/dashboardsTreeItem.fixture.ts | 8 ++ .../features/browse-dashboards/state/hooks.ts | 20 ++++- .../browse-dashboards/state/reducers.test.ts | 61 ++++++++++++++- .../browse-dashboards/state/reducers.ts | 48 ++++++++---- .../app/features/browse-dashboards/types.ts | 2 +- .../search/page/components/columns.tsx | 1 + public/app/features/search/service/utils.ts | 29 +++++++ 16 files changed, 301 insertions(+), 31 deletions(-) diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 3ab369f4510..eb3da391bf3 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -166,6 +166,7 @@ export class GrafanaBootConfig implements GrafanaConfig { tokenExpirationDayLimit: undefined; disableFrontendSandboxForPlugins: string[] = []; + sharedWithMeFolderUID: string | undefined; constructor(options: GrafanaBootConfig) { this.bootData = options.bootData; diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index 8f660156523..286dec961a2 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useId, useMemo, useState } from 'react'; import { useAsync } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Alert, Icon, Input, LoadingBar, useStyles2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; import { skipToken, useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; @@ -164,6 +165,10 @@ export function NestedFolderPicker({ return createFlatTree(undefined, searchCollection, childrenCollections, {}, 0, EXCLUDED_KINDS, excludeUIDs); } + const allExcludedUIDs = config.sharedWithMeFolderUID + ? [...(excludeUIDs || []), config.sharedWithMeFolderUID] + : excludeUIDs; + let flatTree = createFlatTree( undefined, rootCollection, @@ -171,7 +176,7 @@ export function NestedFolderPicker({ folderOpenState, 0, EXCLUDED_KINDS, - excludeUIDs + allExcludedUIDs ); if (showRootFolder) { diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index fc68ca87565..0324a580a28 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -57,6 +57,7 @@ export const browseDashboardsAPI = createApi({ providesTags: (_result, _error, folderUID) => [{ type: 'getFolder', id: folderUID }], query: (folderUID) => ({ url: `/folders/${folderUID}`, params: { accesscontrol: true } }), }), + // create a new folder newFolder: builder.mutation({ query: ({ title, parentUid }) => ({ @@ -81,6 +82,7 @@ export const browseDashboardsAPI = createApi({ }); }, }), + // save an existing folder (e.g. rename) saveFolder: builder.mutation({ // because the getFolder calls contain the parents, renaming a parent/grandparent/etc needs to invalidate all child folders diff --git a/public/app/features/browse-dashboards/api/services.ts b/public/app/features/browse-dashboards/api/services.ts index 468392e965f..f4318793358 100644 --- a/public/app/features/browse-dashboards/api/services.ts +++ b/public/app/features/browse-dashboards/api/services.ts @@ -6,6 +6,7 @@ import { DashboardViewItem } from 'app/features/search/types'; import { contextSrv } from '../../../core/core'; import { AccessControlAction } from '../../../types'; +import { isSharedWithMe } from '../components/utils'; export const PAGE_SIZE = 50; @@ -36,7 +37,7 @@ export async function listFolders( title: item.title, parentTitle, parentUID, - url: `/dashboards/f/${item.uid}/`, + url: isSharedWithMe(item.uid) ? undefined : `/dashboards/f/${item.uid}/`, })); } diff --git a/public/app/features/browse-dashboards/components/CheckboxCell.tsx b/public/app/features/browse-dashboards/components/CheckboxCell.tsx index 83f84c9e039..fb8ffebfe20 100644 --- a/public/app/features/browse-dashboards/components/CheckboxCell.tsx +++ b/public/app/features/browse-dashboards/components/CheckboxCell.tsx @@ -8,26 +8,31 @@ import { t } from 'app/core/internationalization'; import { DashboardsTreeCellProps, SelectionState } from '../types'; +import { isSharedWithMe } from './utils'; + export default function CheckboxCell({ row: { original: row }, isSelected, onItemSelectionChange, }: DashboardsTreeCellProps) { - const styles = useStyles2(getStyles); const item = row.item; if (!isSelected) { - return ; + return ; } if (item.kind === 'ui') { if (item.uiKind === 'pagination-placeholder') { return ; } else { - return ; + return ; } } + if (isSharedWithMe(item.uid)) { + return ; + } + const state = isSelected(item); return ( @@ -41,6 +46,11 @@ export default function CheckboxCell({ ); } +function CheckboxSpacer() { + const styles = useStyles2(getStyles); + return ; +} + const getStyles = (theme: GrafanaTheme2) => ({ // Should be the same size as the so Dashboard name is aligned to Folder name siblings checkboxSpacer: css({ diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx index 8a58cd9c421..caa039829bd 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx @@ -5,8 +5,14 @@ import { TestProvider } from 'test/helpers/TestProvider'; import { assertIsDefined } from 'test/helpers/asserts'; import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; -import { wellFormedDashboard, wellFormedEmptyFolder, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture'; +import { + sharedWithMeFolder, + wellFormedDashboard, + wellFormedEmptyFolder, + wellFormedFolder, +} from '../fixtures/dashboardsTreeItem.fixture'; import { SelectionState } from '../types'; import { DashboardsTree } from './DashboardsTree'; @@ -27,6 +33,10 @@ describe('browse-dashboards DashboardsTree', () => { const allItemsAreLoaded = () => true; const requestLoadMore = () => Promise.resolve(); + beforeAll(() => { + config.sharedWithMeFolderUID = 'sharedwithme'; + }); + it('renders a dashboard item', () => { render( { requestLoadMore={requestLoadMore} /> ); + expect(screen.queryByText(folder.item.title)).toBeInTheDocument(); }); + it('renders a folder link', () => { + render( + + ); + + expect(screen.queryByText(folder.item.title)).toHaveAttribute('href', folder.item.url); + }); + + it("doesn't link to the sharedwithme pseudo-folder", () => { + const sharedWithMe = sharedWithMeFolder(2); + + render( + + ); + + expect(screen.queryByText(sharedWithMe.item.title)).not.toHaveAttribute('href'); + }); + + it("doesn't render a checkbox for the sharedwithme pseudo-folder", () => { + const sharedWithMe = sharedWithMeFolder(2); + + render( + + ); + + expect( + screen.queryByTestId(selectors.pages.BrowseDashboards.table.checkbox(sharedWithMe.item.uid)) + ).not.toBeInTheDocument(); + }); + it('calls onFolderClick when a folder button is clicked', async () => { const handler = jest.fn(); render( diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.tsx index be3b5bee1c7..94d16c5ba77 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.tsx @@ -1,7 +1,7 @@ import { css, cx } from '@emotion/css'; import React, { useCallback, useEffect, useId, useMemo, useRef } from 'react'; import { TableInstance, useTable } from 'react-table'; -import { FixedSizeList as List } from 'react-window'; +import { VariableSizeList as List } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; import { GrafanaTheme2, isTruthy } from '@grafana/data'; @@ -35,6 +35,7 @@ interface DashboardsTreeProps { const HEADER_HEIGHT = 36; const ROW_HEIGHT = 36; +const DIVIDER_HEIGHT = 0; // Yes - make it appear as a border on the row rather than a row itself export function DashboardsTree({ items, @@ -51,6 +52,7 @@ export function DashboardsTree({ const treeID = useId(); const infiniteLoaderRef = useRef(null); + const listRef = useRef(null); const styles = useStyles2(getStyles); useEffect(() => { @@ -60,6 +62,10 @@ export function DashboardsTree({ if (infiniteLoaderRef.current) { infiniteLoaderRef.current.resetloadMoreItemsCache(true); } + + if (listRef.current) { + listRef.current.resetAfterIndex(0); + } }, [items]); const tableColumns = useMemo(() => { @@ -123,6 +129,18 @@ export function DashboardsTree({ [requestLoadMore, items] ); + const getRowHeight = useCallback( + (rowIndex: number) => { + const row = items[rowIndex]; + if (row.item.kind === 'ui' && row.item.uiKind === 'divider') { + return DIVIDER_HEIGHT; + } + + return ROW_HEIGHT; + }, + [items] + ); + return (
{headerGroups.map((headerGroup) => { @@ -154,12 +172,16 @@ export function DashboardsTree({ > {({ onItemsRendered, ref }) => ( { + ref(elem); + listRef.current = elem; + }} height={height - HEADER_HEIGHT} width={width} itemCount={items.length} itemData={virtualData} - itemSize={ROW_HEIGHT} + estimatedItemSize={ROW_HEIGHT} + itemSize={getRowHeight} onItemsRendered={onItemsRendered} > {VirtualListRow} @@ -191,13 +213,23 @@ function VirtualListRow({ index, style, data }: VirtualListRowProps) { const row = rows[index]; prepareRow(row); + const dashboardItem = row.original.item; + + if (dashboardItem.kind === 'ui' && dashboardItem.uiKind === 'divider') { + return ( +
+
+
+ ); + } + return (
{row.cells.map((cell) => { @@ -221,6 +253,12 @@ const getStyles = (theme: GrafanaTheme2) => { gap: theme.spacing(1), }), + divider: css({ + borderTop: `1px solid ${theme.colors.border.weak}`, + width: '100%', + margin: 0, + }), + headerRow: css({ backgroundColor: theme.colors.background.secondary, height: HEADER_HEIGHT, diff --git a/public/app/features/browse-dashboards/components/NameCell.tsx b/public/app/features/browse-dashboards/components/NameCell.tsx index 11bbd5af231..086d3a4d5cc 100644 --- a/public/app/features/browse-dashboards/components/NameCell.tsx +++ b/public/app/features/browse-dashboards/components/NameCell.tsx @@ -7,7 +7,7 @@ import { reportInteraction } from '@grafana/runtime'; import { Icon, IconButton, Link, Spinner, useStyles2, Text } from '@grafana/ui'; import { getSvgSize } from '@grafana/ui/src/components/Icon/utils'; import { t } from 'app/core/internationalization'; -import { getIconForKind } from 'app/features/search/service/utils'; +import { getIconForItem } from 'app/features/search/service/utils'; import { Indent } from '../../../core/components/Indent/Indent'; import { useChildrenByParentUIDState } from '../state'; @@ -27,7 +27,7 @@ export function NameCell({ row: { original: data }, onFolderClick, treeID }: Nam const { item, level, isOpen } = data; const childrenByParentUID = useChildrenByParentUIDState(); const isLoading = isOpen && !childrenByParentUID[item.uid]; - const iconName = getIconForKind(data.item.kind, isOpen); + const iconName = getIconForItem(data.item, isOpen); if (item.kind === 'ui') { return ( diff --git a/public/app/features/browse-dashboards/components/utils.ts b/public/app/features/browse-dashboards/components/utils.ts index 56613b82e14..cc4485b06b8 100644 --- a/public/app/features/browse-dashboards/components/utils.ts +++ b/public/app/features/browse-dashboards/components/utils.ts @@ -1,5 +1,11 @@ +import { config } from '@grafana/runtime'; + import { DashboardViewItemWithUIItems } from '../types'; export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) { return baseId + item.uid; } + +export function isSharedWithMe(uid: string) { + return uid === config.sharedWithMeFolderUID; +} diff --git a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts index 4cb15098aa3..9de6e59c06a 100644 --- a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts +++ b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts @@ -65,6 +65,14 @@ export function wellFormedFolder( }; } +export function sharedWithMeFolder(seed = 1): DashboardsTreeItem { + const folder = wellFormedFolder(seed, undefined, { + uid: 'sharedwithme', + url: undefined, + }); + return folder; +} + export function wellFormedTree() { let seed = 1; diff --git a/public/app/features/browse-dashboards/state/hooks.ts b/public/app/features/browse-dashboards/state/hooks.ts index 9d040810919..d46f0b44708 100644 --- a/public/app/features/browse-dashboards/state/hooks.ts +++ b/public/app/features/browse-dashboards/state/hooks.ts @@ -5,6 +5,7 @@ import { DashboardViewItem } from 'app/features/search/types'; import { useSelector, StoreState, useDispatch } from 'app/types'; import { PAGE_SIZE } from '../api/services'; +import { isSharedWithMe } from '../components/utils'; import { BrowseDashboardsState, DashboardsTreeItem, @@ -130,7 +131,7 @@ export function useLoadNextChildrenPage( } /** - * Creates a list of items, with level indicating it's 'nested' in the tree structure + * Creates a list of items, with level indicating it's nesting in the tree structure * * @param folderUID The UID of the folder being viewed, or undefined if at root Browse Dashboards page * @param rootItems Array of loaded items at the root level (without a parent). If viewing a folder, we expect this to be empty and unused @@ -180,7 +181,22 @@ export function createFlatTree( isOpen, }; - return [thisItem, ...mappedChildren]; + const items = [thisItem, ...mappedChildren]; + + if (isSharedWithMe(thisItem.item.uid)) { + items.push({ + item: { + kind: 'ui', + uiKind: 'divider', + uid: 'shared-with-me-divider', + }, + parentUID, + level: level + 1, + isOpen: false, + }); + } + + return items; } const isOpen = (folderUID && openFolders[folderUID]) || level === 0; diff --git a/public/app/features/browse-dashboards/state/reducers.test.ts b/public/app/features/browse-dashboards/state/reducers.test.ts index e90b484c129..5df508e0852 100644 --- a/public/app/features/browse-dashboards/state/reducers.test.ts +++ b/public/app/features/browse-dashboards/state/reducers.test.ts @@ -1,4 +1,6 @@ -import { wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture'; +import { config } from '@grafana/runtime'; + +import { sharedWithMeFolder, wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture'; import { fullyLoadedViewItemCollection } from '../fixtures/state.fixtures'; import { BrowseDashboardsState } from '../types'; @@ -19,6 +21,10 @@ function createInitialState(): BrowseDashboardsState { } describe('browse-dashboards reducers', () => { + beforeAll(() => { + config.sharedWithMeFolderUID = 'sharedwithme'; + }); + describe('fetchNextChildrenPageFulfilled', () => { it('loads first page of root items', () => { const pageSize = 50; @@ -321,11 +327,34 @@ describe('browse-dashboards reducers', () => { expect(state.selectedItems.$all).toBeFalsy(); }); + + it('does not allow the sharedwithme folder to be selected', () => { + let seed = 1; + const folder = wellFormedFolder(seed++).item; + const dashboard = wellFormedDashboard(seed++).item; + const sharedWithMe = sharedWithMeFolder(seed++).item; + const sharedWithMeDashboard = wellFormedDashboard(seed++, {}, { parentUID: sharedWithMe.uid }).item; + + const state = createInitialState(); + state.rootItems = fullyLoadedViewItemCollection([sharedWithMe, folder, dashboard]); + state.childrenByParentUID[sharedWithMe.uid] = fullyLoadedViewItemCollection([sharedWithMeDashboard]); + + setItemSelectionState(state, { + type: 'setItemSelectionState', + payload: { item: sharedWithMe, isSelected: true }, + }); + + expect(state.selectedItems.folder[sharedWithMe.uid]).toBeFalsy(); + }); }); describe('setAllSelection', () => { let seed = 1; const topLevelDashboard = wellFormedDashboard(seed++).item; + + const sharedWithMe = sharedWithMeFolder(seed++).item; + const sharedWithMeDashboard = wellFormedDashboard(seed++, {}, { parentUID: sharedWithMe.uid }).item; + const topLevelFolder = wellFormedFolder(seed++).item; const childDashboard = wellFormedDashboard(seed++, {}, { parentUID: topLevelFolder.uid }).item; const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item; @@ -407,5 +436,35 @@ describe('browse-dashboards reducers', () => { panel: {}, }); }); + + it("doesn't select the sharedwithme folder when selecting all", () => { + const state = createInitialState(); + + state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, sharedWithMe]); + state.childrenByParentUID[sharedWithMe.uid] = fullyLoadedViewItemCollection([sharedWithMeDashboard]); + state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]); + + setAllSelection(state, { type: 'setAllSelection', payload: { isSelected: true, folderUID: undefined } }); + + expect(state.selectedItems.folder[sharedWithMe.uid]).toBeFalsy(); + expect(state.selectedItems.dashboard[sharedWithMeDashboard.uid]).toBeFalsy(); + }); + + it("doesn't select anything when on the sharedwithme folder page", () => { + const state = createInitialState(); + + state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, topLevelDashboard]); + state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]); + state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]); + + setAllSelection(state, { type: 'setAllSelection', payload: { isSelected: true, folderUID: sharedWithMe.uid } }); + + expect(state.selectedItems).toEqual({ + $all: false, + dashboard: {}, + folder: {}, + panel: {}, + }); + }); }); }); diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts index 0eafcb939be..e506748dcd6 100644 --- a/public/app/features/browse-dashboards/state/reducers.ts +++ b/public/app/features/browse-dashboards/state/reducers.ts @@ -2,6 +2,7 @@ import { PayloadAction } from '@reduxjs/toolkit'; import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; +import { isSharedWithMe } from '../components/utils'; import { BrowseDashboardsState } from '../types'; import { fetchNextChildrenPage, refetchChildren } from './actions'; @@ -86,6 +87,11 @@ export function setItemSelectionState( ) { const { item, isSelected } = action.payload; + // UI shouldn't allow it, but also prevent sharedwithme from being selected + if (isSharedWithMe(item.uid)) { + return; + } + // Selecting a folder selects all children, and unselecting a folder deselects all children // so propagate the new selection state to all descendants function markChildren(kind: DashboardViewItemKind, uid: string) { @@ -103,26 +109,24 @@ export function setItemSelectionState( markChildren(item.kind, item.uid); - // If all children of a folder are selected, then the folder is also selected. - // If *any* child of a folder is unselelected, then the folder is alo unselected. - // Reconcile all ancestors to make sure they're in the correct state. - let nextParentUID = item.parentUID; + // If we're unselecting a child, we also need to unselect all ancestors. + if (!isSelected) { + let nextParentUID = item.parentUID; - while (nextParentUID) { - const parent = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, nextParentUID); + while (nextParentUID) { + const parent = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, nextParentUID); - // This case should not happen, but a find can theortically return undefined, and it - // helps limit infinite loops - if (!parent) { - break; - } + // This case should not happen, but a find can theortically return undefined, and it + // helps limit infinite loops + if (!parent) { + break; + } - if (!isSelected) { // A folder cannot be selected if any of it's children are unselected state.selectedItems[parent.kind][parent.uid] = false; - } - nextParentUID = parent.parentUID; + nextParentUID = parent.parentUID; + } } // Check to see if we should mark the header checkbox selected if all root items are selected @@ -135,6 +139,12 @@ export function setAllSelection( ) { const { isSelected, folderUID: folderUIDArg } = action.payload; + // If we're in the folder view for sharedwith me (currently not supported) + // bail and don't select anything + if (folderUIDArg && isSharedWithMe(folderUIDArg)) { + return; + } + state.selectedItems.$all = isSelected; // Search works a bit differently so the state here does different things... @@ -146,6 +156,11 @@ export function setAllSelection( if (isSelected) { // Recursively select the children of the folder in view function selectChildrenOfFolder(folderUID: string | undefined) { + // Don't descend into the sharedwithme folder + if (folderUID && isSharedWithMe(folderUID)) { + return; + } + const collection = folderUID ? state.childrenByParentUID[folderUID] : state.rootItems; // Bail early if the collection isn't found (not loaded yet) @@ -154,6 +169,11 @@ export function setAllSelection( } for (const child of collection.items) { + // Don't traverse into the sharedwithme folder + if (isSharedWithMe(child.uid)) { + continue; + } + state.selectedItems[child.kind][child.uid] = isSelected; if (child.kind !== 'folder') { diff --git a/public/app/features/browse-dashboards/types.ts b/public/app/features/browse-dashboards/types.ts index 8b0c0dc1ed5..db797d0b0f6 100644 --- a/public/app/features/browse-dashboards/types.ts +++ b/public/app/features/browse-dashboards/types.ts @@ -29,7 +29,7 @@ export interface BrowseDashboardsState { export interface UIDashboardViewItem { kind: 'ui'; - uiKind: 'empty-folder' | 'pagination-placeholder'; + uiKind: 'empty-folder' | 'pagination-placeholder' | 'divider'; uid: string; } diff --git a/public/app/features/search/page/components/columns.tsx b/public/app/features/search/page/components/columns.tsx index 34625204aa1..912b02fe029 100644 --- a/public/app/features/search/page/components/columns.tsx +++ b/public/app/features/search/page/components/columns.tsx @@ -178,6 +178,7 @@ export const generateColumns = ( return info ? ( + {info.name} diff --git a/public/app/features/search/service/utils.ts b/public/app/features/search/service/utils.ts index b4cb61756b8..6aeeaefba92 100644 --- a/public/app/features/search/service/utils.ts +++ b/public/app/features/search/service/utils.ts @@ -1,4 +1,6 @@ import { DataFrameView, IconName } from '@grafana/data'; +import { isSharedWithMe } from 'app/features/browse-dashboards/components/utils'; +import { DashboardViewItemWithUIItems } from 'app/features/browse-dashboards/types'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { DashboardViewItem, DashboardViewItemKind } from '../types'; @@ -50,6 +52,33 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName { return 'question-circle'; } +export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: boolean): IconName { + if (item && isSharedWithMe(item.uid)) { + return 'users-alt'; + } else { + return getIconForKind(item.kind, isOpen); + } +} + +// export function getIconForItem(itemOrKind: string | DashboardViewItemWithUIItems, isOpen?: boolean): IconName { +// const kind = typeof itemOrKind === 'string' ? itemOrKind : itemOrKind.kind; +// const item = typeof itemOrKind === 'string' ? undefined : itemOrKind; + +// if (kind === 'dashboard') { +// return 'apps'; +// } + +// if (item && isSharedWithMe(item.uid)) { +// return 'users-alt'; +// } + +// if (kind === 'folder') { +// return isOpen ? 'folder-open' : 'folder'; +// } + +// return 'question-circle'; +// } + function parseKindString(kind: string): DashboardViewItemKind { switch (kind) { case 'dashboard': From 343422537ee764b009c8a50dd9311e4e407aebf8 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Mon, 15 Jan 2024 12:11:09 +0100 Subject: [PATCH 12/29] Chore: Fix Azure Monitor plugin build (#80528) --- .github/workflows/core-plugins-build-and-release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/core-plugins-build-and-release.yml b/.github/workflows/core-plugins-build-and-release.yml index 8263f3dce1a..6a79396e37a 100644 --- a/.github/workflows/core-plugins-build-and-release.yml +++ b/.github/workflows/core-plugins-build-and-release.yml @@ -1,3 +1,5 @@ +name: Build and release core plugins + on: workflow_dispatch: inputs: @@ -61,7 +63,12 @@ jobs: shell: bash id: get_dir run: | - dir=$(find public/app/plugins -name ${{ inputs.plugin_id }} -print -quit) + dir=$(dirname \ + $(egrep -lir --include=plugin.json --exclude-dir=dist \ + '"id": "${{ inputs.plugin_id }}"' \ + public/app/plugins \ + ) \ + ) echo "dir=${dir}" >> $GITHUB_OUTPUT - name: Install frontend dependencies shell: bash From 15af2e5053d78ed0061f74953979e7e2b93811f9 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 15 Jan 2024 11:30:55 +0000 Subject: [PATCH 13/29] Connections: Pass keywords from the backend to CommandPalette (#80276) * pass keywords from the backend to CommandPalette * add csv and json to keywords --- packages/grafana-data/src/types/navModel.ts | 2 ++ pkg/services/navtree/models.go | 1 + pkg/services/navtree/navtreeimpl/navtree.go | 1 + public/app/features/commandPalette/actions/staticActions.ts | 3 ++- 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 0f6acfe430a..066d879df3d 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -26,6 +26,8 @@ export interface NavLinkDTO { pluginId?: string; // Whether the page is used to create a new resource. We may place these in a different position in the UI. isCreateAction?: boolean; + // Optional keywords to match on when searching (e.g. in the CommandPalette) + keywords?: string[]; } export interface NavModelItem extends NavLinkDTO { diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index 17ec7ed67fa..22e0e412558 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -67,6 +67,7 @@ type NavLink struct { EmptyMessageId string `json:"emptyMessageId,omitempty"` PluginID string `json:"pluginId,omitempty"` // (Optional) The ID of the plugin that registered nav link (e.g. as a standalone plugin page) IsCreateAction bool `json:"isCreateAction,omitempty"` + Keywords []string `json:"keywords,omitempty"` } func (node *NavLink) Sort() { diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 20e56c7865d..ef5c52f7ba1 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -500,6 +500,7 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *n SubTitle: "Browse and create new connections", Url: baseUrl + "/add-new-connection", Children: []*navtree.NavLink{}, + Keywords: []string{"csv", "graphite", "json", "loki", "prometheus", "sql", "tempo"}, }) // Data sources diff --git a/public/app/features/commandPalette/actions/staticActions.ts b/public/app/features/commandPalette/actions/staticActions.ts index 9d4e9dbd4ff..ed93b54d5e9 100644 --- a/public/app/features/commandPalette/actions/staticActions.ts +++ b/public/app/features/commandPalette/actions/staticActions.ts @@ -22,7 +22,7 @@ function navTreeToActions(navTree: NavModelItem[], parents: NavModelItem[] = []) navItem = enrichHelpItem({ ...navItem }); delete navItem.url; } - const { url, target, text, isCreateAction, children, onClick } = navItem; + const { url, target, text, isCreateAction, children, onClick, keywords } = navItem; const hasChildren = Boolean(children?.length); if (!(url || onClick || hasChildren)) { @@ -44,6 +44,7 @@ function navTreeToActions(navTree: NavModelItem[], parents: NavModelItem[] = []) target, parent: parents.length > 0 && !isCreateAction ? idForNavItem(parents[parents.length - 1]) : undefined, perform: onClick, + keywords: keywords?.join(' '), priority: priority, subtitle: isCreateAction ? undefined : subtitle, }; From d91d4e87b9532c2d8e773d516575e5b037f6096c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 11:14:33 +0000 Subject: [PATCH 14/29] Update dependency immer to v10.0.3 --- package.json | 2 +- .../app/plugins/datasource/azuremonitor/package.json | 2 +- yarn.lock | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index e07c69f7d97..60e7339e591 100644 --- a/package.json +++ b/package.json @@ -330,7 +330,7 @@ "hoist-non-react-statics": "3.3.2", "i18next": "^22.0.0", "i18next-browser-languagedetector": "^7.0.2", - "immer": "10.0.2", + "immer": "10.0.3", "immutable": "4.3.1", "jquery": "3.7.0", "js-yaml": "^4.1.0", diff --git a/public/app/plugins/datasource/azuremonitor/package.json b/public/app/plugins/datasource/azuremonitor/package.json index e625a24cac8..b71dd002b78 100644 --- a/public/app/plugins/datasource/azuremonitor/package.json +++ b/public/app/plugins/datasource/azuremonitor/package.json @@ -13,7 +13,7 @@ "@kusto/monaco-kusto": "^7.4.0", "fast-deep-equal": "^3.1.3", "i18next": "^22.0.0", - "immer": "10.0.2", + "immer": "10.0.3", "lodash": "4.17.21", "monaco-editor": "0.34.0", "prismjs": "1.29.0", diff --git a/yarn.lock b/yarn.lock index b019b3a0424..79a3dbb77b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2953,7 +2953,7 @@ __metadata: "@types/testing-library__jest-dom": "npm:5.14.9" fast-deep-equal: "npm:^3.1.3" i18next: "npm:^22.0.0" - immer: "npm:10.0.2" + immer: "npm:10.0.3" lodash: "npm:4.17.21" monaco-editor: "npm:0.34.0" prismjs: "npm:1.29.0" @@ -17207,7 +17207,7 @@ __metadata: i18next: "npm:^22.0.0" i18next-browser-languagedetector: "npm:^7.0.2" i18next-parser: "npm:6.6.0" - immer: "npm:10.0.2" + immer: "npm:10.0.3" immutable: "npm:4.3.1" jest: "npm:29.7.0" jest-canvas-mock: "npm:2.5.2" @@ -18149,10 +18149,10 @@ __metadata: languageName: node linkType: hard -"immer@npm:10.0.2": - version: 10.0.2 - resolution: "immer@npm:10.0.2" - checksum: 5fcddbbc036428bb3db1af66d6f6c3aaf9dfb21ab3e476894f45e3b60e35fb64af67ffab9e626770ab0154d5ca83895038a0af7c25513144e19cba1ab19ec4ef +"immer@npm:10.0.3": + version: 10.0.3 + resolution: "immer@npm:10.0.3" + checksum: 0be07be2f278bd1988112613648e0cf9a64fc316d5b4817f273b519fbfed0f1714275b041911f0b8c560c199b2e3430824ce620c23262c96c9d4efc9909ff1cc languageName: node linkType: hard From ec53487c995777b314f566f5a1054e3f8e29ec05 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 15 Jan 2024 11:43:19 +0000 Subject: [PATCH 15/29] NestedFolderPicker: separate toggle to force enable picker without `nestedFolders` (#80461) * separate nestedFolderPickerOverride toggle to force enable it without nestedFolders * let's call it newFolderPicker * update unit tests and keyboard handling * reduce spacing when no folder open chevron --------- Co-authored-by: Josh Hunt --- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 8 ++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + .../NestedFolderPicker/NestedFolderList.tsx | 5 +- .../NestedFolderPicker.test.tsx | 134 ++++++++++++------ .../NestedFolderPicker/NestedFolderPicker.tsx | 3 +- .../components/NestedFolderPicker/hooks.ts | 6 +- .../core/components/Select/FolderPicker.tsx | 4 +- 10 files changed, 119 insertions(+), 48 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 3cc5f4734ea..707b05659ea 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -172,6 +172,7 @@ Experimental features might be changed or removed without prior notice. | `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes | | `tableSharedCrosshair` | Enables shared crosshair in table panel | | `enablePluginsTracingByDefault` | Enable plugin tracing for all external plugins | +| `newFolderPicker` | Enables the nested folder picker without having nested folders enabled | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c4ed31183e2..23965542e01 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -174,4 +174,5 @@ export interface FeatureToggles { enablePluginsTracingByDefault?: boolean; cloudRBACRoles?: boolean; alertingQueryOptimization?: boolean; + newFolderPicker?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 13c2f6ddd05..7cfea546948 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1323,5 +1323,13 @@ var ( AllowSelfServe: false, Created: time.Date(2024, time.January, 10, 12, 0, 0, 0, time.UTC), }, + { + Name: "newFolderPicker", + Description: "Enables the nested folder picker without having nested folders enabled", + Stage: FeatureStageExperimental, + Owner: grafanaFrontendPlatformSquad, + FrontendOnly: true, + Created: time.Date(2024, time.January, 12, 12, 0, 0, 0, time.UTC), + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 60838330e1a..9b896c5298f 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -155,3 +155,4 @@ alertingPreviewUpgrade,experimental,@grafana/alerting-squad,2024-01-03,false,fal enablePluginsTracingByDefault,experimental,@grafana/plugins-platform-backend,2024-01-09,false,false,true,false cloudRBACRoles,experimental,@grafana/identity-access-team,2024-01-10,false,false,true,false alertingQueryOptimization,GA,@grafana/alerting-squad,2024-01-10,false,false,false,false +newFolderPicker,experimental,@grafana/grafana-frontend-platform,2024-01-12,false,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 42e47472d84..ad24f971057 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -630,4 +630,8 @@ const ( // FlagAlertingQueryOptimization // Optimizes eligible queries in order to reduce load on datasources FlagAlertingQueryOptimization = "alertingQueryOptimization" + + // FlagNewFolderPicker + // Enables the nested folder picker without having nested folders enabled + FlagNewFolderPicker = "newFolderPicker" ) diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx index f0874362c93..7de1643f74d 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx @@ -6,7 +6,6 @@ import InfiniteLoader from 'react-window-infinite-loader'; import { GrafanaTheme2 } from '@grafana/data'; import { IconButton, useStyles2 } from '@grafana/ui'; -import { getSvgSize } from '@grafana/ui/src/components/Icon/utils'; import { Text } from '@grafana/ui/src/components/Text/Text'; import { Indent } from 'app/core/components/Indent/Indent'; import { Trans } from 'app/core/internationalization'; @@ -191,6 +190,7 @@ function Row({ index, style: virtualStyles, data }: RowProps) { >
+ {foldersAreOpenable ? ( { width: '100%', }), - // Should be the same size as the for proper alignment folderButtonSpacer: css({ - paddingLeft: `calc(${getSvgSize(CHEVRON_SIZE)}px + ${theme.spacing(0.5)})`, + paddingLeft: theme.spacing(0.5), }), row: css({ diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx index fc71b260d5c..3c24d81440b 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx @@ -6,6 +6,7 @@ import { SetupServer, setupServer } from 'msw/node'; import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; +import { config } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { wellFormedTree } from '../../../features/browse-dashboards/fixtures/dashboardsTreeItem.fixture'; @@ -122,60 +123,111 @@ describe('NestedFolderPicker', () => { expect(mockOnChange).toHaveBeenCalledWith(folderA.item.uid, folderA.item.title); }); - it('can expand and collapse a folder to show its children', async () => { - render(); + describe('when nestedFolders is enabled', () => { + let originalToggles = { ...config.featureToggles }; - // Open the picker and wait for children to load - const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); - await screen.findByLabelText(folderA.item.title); + beforeAll(() => { + config.featureToggles.nestedFolders = true; + }); - // Expand Folder A - // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly - fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` })); + afterAll(() => { + config.featureToggles = originalToggles; + }); - // Folder A's children are visible - expect(await screen.findByLabelText(folderA_folderA.item.title)).toBeInTheDocument(); - expect(await screen.findByLabelText(folderA_folderB.item.title)).toBeInTheDocument(); + it('can expand and collapse a folder to show its children', async () => { + render(); - // Collapse Folder A - // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly - fireEvent.mouseDown(screen.getByRole('button', { name: `Collapse folder ${folderA.item.title}` })); - expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument(); - expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument(); + // Open the picker and wait for children to load + const button = await screen.findByRole('button', { name: 'Select folder' }); + await userEvent.click(button); + await screen.findByLabelText(folderA.item.title); - // Expand Folder A again - // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly - fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` })); + // Expand Folder A + // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly + fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` })); - // Select the first child - await userEvent.click(screen.getByLabelText(folderA_folderA.item.title)); - expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title); + // Folder A's children are visible + expect(await screen.findByLabelText(folderA_folderA.item.title)).toBeInTheDocument(); + expect(await screen.findByLabelText(folderA_folderB.item.title)).toBeInTheDocument(); + + // Collapse Folder A + // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly + fireEvent.mouseDown(screen.getByRole('button', { name: `Collapse folder ${folderA.item.title}` })); + expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument(); + + // Expand Folder A again + // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly + fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` })); + + // Select the first child + await userEvent.click(screen.getByLabelText(folderA_folderA.item.title)); + expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title); + }); + + it('can expand and collapse a folder to show its children with the keyboard', async () => { + render(); + const button = await screen.findByRole('button', { name: 'Select folder' }); + + await userEvent.click(button); + + // Expand Folder A + await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}'); + + // Folder A's children are visible + expect(screen.getByLabelText(folderA_folderA.item.title)).toBeInTheDocument(); + expect(screen.getByLabelText(folderA_folderB.item.title)).toBeInTheDocument(); + + // Collapse Folder A + await userEvent.keyboard('{ArrowLeft}'); + expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument(); + + // Expand Folder A again + await userEvent.keyboard('{ArrowRight}'); + + // Select the first child + await userEvent.keyboard('{ArrowDown}{Enter}'); + expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title); + }); }); - it('can expand and collapse a folder to show its children with the keyboard', async () => { - render(); - const button = await screen.findByRole('button', { name: 'Select folder' }); + describe('when nestedFolders is disabled', () => { + let originalToggles = { ...config.featureToggles }; - await userEvent.click(button); + beforeAll(() => { + config.featureToggles.nestedFolders = false; + }); - // Expand Folder A - await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}'); + afterAll(() => { + config.featureToggles = originalToggles; + }); - // Folder A's children are visible - expect(screen.getByLabelText(folderA_folderA.item.title)).toBeInTheDocument(); - expect(screen.getByLabelText(folderA_folderB.item.title)).toBeInTheDocument(); + it('does not show an expand button', async () => { + render(); - // Collapse Folder A - await userEvent.keyboard('{ArrowLeft}'); - expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument(); - expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument(); + // Open the picker and wait for children to load + const button = await screen.findByRole('button', { name: 'Select folder' }); + await userEvent.click(button); + await screen.findByLabelText(folderA.item.title); - // Expand Folder A again - await userEvent.keyboard('{ArrowRight}'); + // There should be no expand button + // Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly + expect(screen.queryByRole('button', { name: `Expand folder ${folderA.item.title}` })).not.toBeInTheDocument(); + }); - // Select the first child - await userEvent.keyboard('{ArrowDown}{Enter}'); - expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title); + it('does not expand a folder with the keyboard', async () => { + render(); + const button = await screen.findByRole('button', { name: 'Select folder' }); + + await userEvent.click(button); + + // try to expand Folder A + await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}'); + + // Folder A's children are not visible + expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument(); + }); }); }); diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index 286dec961a2..120c918180b 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -59,6 +59,7 @@ export function NestedFolderPicker({ const selectedFolder = useGetFolderQuery(value || skipToken); const rootStatus = useBrowseLoadingStatus(undefined); + const nestedFoldersEnabled = Boolean(config.featureToggles.nestedFolders); const [search, setSearch] = useState(''); const [autoFocusButton, setAutoFocusButton] = useState(false); @@ -310,7 +311,7 @@ export function NestedFolderPicker({ onFolderExpand={handleFolderExpand} onFolderSelect={handleFolderSelect} idPrefix={overlayId} - foldersAreOpenable={!(search && searchState.value)} + foldersAreOpenable={nestedFoldersEnabled && !(search && searchState.value)} isItemLoaded={isItemLoaded} requestLoadMore={handleLoadMore} /> diff --git a/public/app/core/components/NestedFolderPicker/hooks.ts b/public/app/core/components/NestedFolderPicker/hooks.ts index 29a76e8c28f..393d35320e5 100644 --- a/public/app/core/components/NestedFolderPicker/hooks.ts +++ b/public/app/core/components/NestedFolderPicker/hooks.ts @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; +import { config } from '@grafana/runtime'; import { DashboardsTreeItem } from 'app/features/browse-dashboards/types'; import { DashboardViewItem } from 'app/features/search/types'; @@ -25,6 +26,7 @@ export function useTreeInteractions({ visible, }: TreeInteractionProps) { const [focusedItemIndex, setFocusedItemIndex] = useState(-1); + const nestedFoldersEnabled = Boolean(config.featureToggles.nestedFolders); useEffect(() => { if (visible) { @@ -44,7 +46,7 @@ export function useTreeInteractions({ const handleKeyDown = useCallback( (ev: React.KeyboardEvent) => { - const foldersAreOpenable = !search; + const foldersAreOpenable = nestedFoldersEnabled && !search; switch (ev.key) { // Expand/collapse folder on right/left arrow keys case 'ArrowRight': @@ -84,7 +86,7 @@ export function useTreeInteractions({ break; } }, - [focusedItemIndex, handleCloseOverlay, handleFolderExpand, handleFolderSelect, search, tree] + [focusedItemIndex, handleCloseOverlay, handleFolderExpand, handleFolderSelect, nestedFoldersEnabled, search, tree] ); return { diff --git a/public/app/core/components/Select/FolderPicker.tsx b/public/app/core/components/Select/FolderPicker.tsx index 71cfb8262ff..1611b961366 100644 --- a/public/app/core/components/Select/FolderPicker.tsx +++ b/public/app/core/components/Select/FolderPicker.tsx @@ -28,7 +28,9 @@ interface FolderPickerProps extends NestedFolderPickerProps { // Temporary wrapper component to switch between the NestedFolderPicker and the old flat // FolderPicker depending on feature flags export function FolderPicker(props: FolderPickerProps) { - const nestedEnabled = config.featureToggles.nestedFolders && config.featureToggles.nestedFolderPicker; + const nestedEnabled = + config.featureToggles.newFolderPicker || + (config.featureToggles.nestedFolders && config.featureToggles.nestedFolderPicker); const { initialTitle, dashboardId, enableCreateNew, ...newFolderPickerProps } = props; return nestedEnabled ? : ; From a3b9ec21db4e50a90e049132723af118dc3f39b3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 11:34:39 +0000 Subject: [PATCH 16/29] Update dependency immutable to v4.3.4 --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 60e7339e591..cdd176c72b3 100644 --- a/package.json +++ b/package.json @@ -331,7 +331,7 @@ "i18next": "^22.0.0", "i18next-browser-languagedetector": "^7.0.2", "immer": "10.0.3", - "immutable": "4.3.1", + "immutable": "4.3.4", "jquery": "3.7.0", "js-yaml": "^4.1.0", "json-markup": "^1.1.0", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 4e37b43ecd2..4a4019d8f9c 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -72,7 +72,7 @@ "hoist-non-react-statics": "3.3.2", "i18next": "^22.0.0", "i18next-browser-languagedetector": "^7.0.2", - "immutable": "4.3.1", + "immutable": "4.3.4", "is-hotkey": "0.2.0", "jquery": "3.7.0", "lodash": "4.17.21", diff --git a/yarn.lock b/yarn.lock index 79a3dbb77b3..1a6dd567db2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3586,7 +3586,7 @@ __metadata: hoist-non-react-statics: "npm:3.3.2" i18next: "npm:^22.0.0" i18next-browser-languagedetector: "npm:^7.0.2" - immutable: "npm:4.3.1" + immutable: "npm:4.3.4" is-hotkey: "npm:0.2.0" jquery: "npm:3.7.0" lodash: "npm:4.17.21" @@ -17208,7 +17208,7 @@ __metadata: i18next-browser-languagedetector: "npm:^7.0.2" i18next-parser: "npm:6.6.0" immer: "npm:10.0.3" - immutable: "npm:4.3.1" + immutable: "npm:4.3.4" jest: "npm:29.7.0" jest-canvas-mock: "npm:2.5.2" jest-date-mock: "npm:1.0.8" @@ -18163,10 +18163,10 @@ __metadata: languageName: node linkType: hard -"immutable@npm:4.3.1, immutable@npm:^4.0.0": - version: 4.3.1 - resolution: "immutable@npm:4.3.1" - checksum: 8b37052a6e91b223e9a6d7b0e6fb4917fdfe8ba5aa4370643eaa135a0174c72fafe2cfead4e0850fc38b4621d0291a187fa1c11f9d37ab06564199de86ede824 +"immutable@npm:4.3.4, immutable@npm:^4.0.0": + version: 4.3.4 + resolution: "immutable@npm:4.3.4" + checksum: ea187acc1eec9dcfaa0823bae59e1ae0ea82e7a40d2ace9fb84d467875d5506ced684a79b68e70451f1e1761a387a958ba724171f93aa10330998b026fcb5d29 languageName: node linkType: hard From 6a36525d612cd1ee6c4d6b824383fa32c137d2e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Mon, 15 Jan 2024 12:50:01 +0100 Subject: [PATCH 17/29] postgres: better error handling (#80375) --- pkg/tsdb/grafana-postgresql-datasource/postgres.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres.go b/pkg/tsdb/grafana-postgresql-datasource/postgres.go index 7a23650d6dc..4894381ab76 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres.go @@ -105,7 +105,8 @@ func (s *Service) newInstanceSettings(cfg *setting.Cfg) datasource.InstanceFacto if sdkproxy.New(proxyOpts).SecureSocksProxyEnabled() { driverName, err = createPostgresProxyDriver(cnnstr, proxyOpts) if err != nil { - return "", nil + logger.Error("postgres proxy creation failed", "error", err) + return nil, fmt.Errorf("postgres proxy creation failed") } } From e2b706fdd3c15b52b187401394249eba50fe6f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Szab=C3=B3?= Date: Mon, 15 Jan 2024 13:02:00 +0100 Subject: [PATCH 18/29] Jaeger: Add service dependency graph support (#72200) * Jaeger: Add service dependency graph support Add support for visualizing Jaeger's service dependency graph via the Jaeger data source. Per the discussion[1], this is done by proxying the internal Jaeger HTTP API endpoint used by Jaeger's own UI for fetching graph data, and transforming it into a format suitable for the node graph panel in Grafana. --- [1] https://github.com/grafana/grafana/discussions/52035 * Small lint fixes * Type fix --------- Co-authored-by: Andrej Ocenas --- docs/sources/datasources/jaeger/_index.md | 5 + .../jaeger/components/QueryEditor.tsx | 3 + .../plugins/datasource/jaeger/datasource.ts | 9 ++ .../jaeger/dependencyGraphTransform.test.ts | 106 +++++++++++++++ .../jaeger/dependencyGraphTransform.ts | 125 ++++++++++++++++++ public/app/plugins/datasource/jaeger/types.ts | 11 +- 6 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 public/app/plugins/datasource/jaeger/dependencyGraphTransform.test.ts create mode 100644 public/app/plugins/datasource/jaeger/dependencyGraphTransform.ts diff --git a/docs/sources/datasources/jaeger/_index.md b/docs/sources/datasources/jaeger/_index.md index 91143aed8b5..4f0e9ee0fa7 100644 --- a/docs/sources/datasources/jaeger/_index.md +++ b/docs/sources/datasources/jaeger/_index.md @@ -369,3 +369,8 @@ To configure this feature, see the [introduction to exemplars][exemplars] docume [variable-syntax]: "/docs/grafana/ -> /docs/grafana//dashboards/variables/variable-syntax" [variable-syntax]: "/docs/grafana-cloud/ -> /docs/grafana//dashboards/variables/variable-syntax" {{% /docs/reference %}} + +## Visualizing the dependency graph + +If service dependency information is available in Jaeger, it can be visualized in Grafana. +Use the Jaeger data source with the "Dependency Graph" query type on a Node Graph panel for this. diff --git a/public/app/plugins/datasource/jaeger/components/QueryEditor.tsx b/public/app/plugins/datasource/jaeger/components/QueryEditor.tsx index 6b0a7c3a9a4..ed9c9e0f084 100644 --- a/public/app/plugins/datasource/jaeger/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/jaeger/components/QueryEditor.tsx @@ -36,6 +36,8 @@ export function QueryEditor({ datasource, query, onChange, onRunQuery }: Props) switch (query.queryType) { case 'search': return ; + case 'dependencyGraph': + return null; default: return ( @@ -79,6 +81,7 @@ export function QueryEditor({ datasource, query, onChange, onRunQuery }: Props) options={[ { value: 'search', label: 'Search' }, { value: undefined, label: 'TraceID' }, + { value: 'dependencyGraph', label: 'Dependency graph' }, ]} value={query.queryType} onChange={(v) => diff --git a/public/app/plugins/datasource/jaeger/datasource.ts b/public/app/plugins/datasource/jaeger/datasource.ts index ab5c89e7407..2ccee1ad1f1 100644 --- a/public/app/plugins/datasource/jaeger/datasource.ts +++ b/public/app/plugins/datasource/jaeger/datasource.ts @@ -22,6 +22,7 @@ import { SpanBarOptions } from 'app/features/explore/TraceView/components'; import { ALL_OPERATIONS_KEY } from './components/SearchForm'; import { TraceIdTimeParamsOptions } from './configuration/TraceIdTimeParams'; +import { mapJaegerDependenciesResponse } from './dependencyGraphTransform'; import { createGraphFrames } from './graphTransform'; import { createTableFrame, createTraceFrame } from './responseTransform'; import { JaegerQuery } from './types'; @@ -65,6 +66,14 @@ export class JaegerDatasource extends DataSourceApi return of({ data: [emptyTraceDataFrame] }); } + // Use the internal Jaeger /dependencies API for rendering the dependency graph. + if (target.queryType === 'dependencyGraph') { + const timeRange = this.timeSrv.timeRange(); + const endTs = getTime(timeRange.to, true) / 1000; + const lookback = endTs - getTime(timeRange.from, false) / 1000; + return this._request('/api/dependencies', { endTs, lookback }).pipe(map(mapJaegerDependenciesResponse)); + } + if (target.queryType === 'search' && !this.isSearchFormValid(target)) { return of({ error: { message: 'You must select a service.' }, data: [] }); } diff --git a/public/app/plugins/datasource/jaeger/dependencyGraphTransform.test.ts b/public/app/plugins/datasource/jaeger/dependencyGraphTransform.test.ts new file mode 100644 index 00000000000..e17621ba45c --- /dev/null +++ b/public/app/plugins/datasource/jaeger/dependencyGraphTransform.test.ts @@ -0,0 +1,106 @@ +import { mapJaegerDependenciesResponse } from './dependencyGraphTransform'; + +describe('dependencyGraphTransform', () => { + it('should transform Jaeger dependencies API response', () => { + const data = { + data: [ + { + parent: 'serviceA', + child: 'serviceB', + callCount: 1, + }, + { + parent: 'serviceA', + child: 'serviceC', + callCount: 2, + }, + { + parent: 'serviceB', + child: 'serviceC', + callCount: 3, + }, + ], + total: 0, + limit: 0, + offset: 0, + }; + + const res = mapJaegerDependenciesResponse({ data }); + expect(res).toMatchObject({ + data: [ + { + fields: [ + { + config: {}, + name: 'id', + type: 'string', + values: ['serviceA', 'serviceB', 'serviceC'], + }, + { + config: {}, + name: 'title', + type: 'string', + values: ['serviceA', 'serviceB', 'serviceC'], + }, + ], + meta: { preferredVisualisationType: 'nodeGraph' }, + }, + { + fields: [ + { + config: {}, + name: 'id', + type: 'string', + values: ['serviceA--serviceB', 'serviceA--serviceC', 'serviceB--serviceC'], + }, + { + config: {}, + name: 'target', + type: 'string', + values: ['serviceB', 'serviceC', 'serviceC'], + }, + { + config: {}, + name: 'source', + type: 'string', + values: ['serviceA', 'serviceA', 'serviceB'], + }, + { + config: { displayName: 'Call count' }, + name: 'mainstat', + type: 'string', + values: [1, 2, 3], + }, + ], + meta: { preferredVisualisationType: 'nodeGraph' }, + }, + ], + }); + }); + + it('should transform Jaeger API error', () => { + const data = { + total: 0, + limit: 0, + offset: 0, + errors: [ + { + code: 400, + msg: 'unable to parse param \'endTs\': strconv.ParseInt: parsing "foo": invalid syntax', + }, + ], + }; + + const res = mapJaegerDependenciesResponse({ data }); + + expect(res).toEqual({ + data: [], + errors: [ + { + message: 'unable to parse param \'endTs\': strconv.ParseInt: parsing "foo": invalid syntax', + status: 400, + }, + ], + }); + }); +}); diff --git a/public/app/plugins/datasource/jaeger/dependencyGraphTransform.ts b/public/app/plugins/datasource/jaeger/dependencyGraphTransform.ts new file mode 100644 index 00000000000..0e8fe6dce46 --- /dev/null +++ b/public/app/plugins/datasource/jaeger/dependencyGraphTransform.ts @@ -0,0 +1,125 @@ +import { + DataFrame, + DataQueryResponse, + FieldType, + MutableDataFrame, + NodeGraphDataFrameFieldNames as Fields, +} from '@grafana/data'; + +import { JaegerServiceDependency } from './types'; + +interface Node { + [Fields.id]: string; + [Fields.title]: string; +} + +interface Edge { + [Fields.id]: string; + [Fields.target]: string; + [Fields.source]: string; + [Fields.mainStat]: number; +} + +/** + * Error schema used by the Jaeger dependencies API. + */ +interface JaegerDependenciesResponseError { + code: number; + msg: string; +} + +interface JaegerDependenciesResponse { + data?: { + errors?: JaegerDependenciesResponseError[]; + data?: JaegerServiceDependency[]; + }; +} + +/** + * Transforms a Jaeger dependencies API response to a Grafana {@link DataQueryResponse}. + * @param response Raw response data from the API proxy. + */ +export function mapJaegerDependenciesResponse(response: JaegerDependenciesResponse): DataQueryResponse { + const errors = response?.data?.errors; + if (errors) { + return { + data: [], + errors: errors.map((e: JaegerDependenciesResponseError) => ({ message: e.msg, status: e.code })), + }; + } + const dependencies = response?.data?.data; + if (dependencies) { + return { + data: convertDependenciesToGraph(dependencies), + }; + } + + return { data: [] }; +} + +/** + * Converts a list of Jaeger service dependencies to a Grafana {@link DataFrame} array suitable for the node graph panel. + * @param dependencies List of Jaeger service dependencies as returned by the Jaeger dependencies API. + */ +function convertDependenciesToGraph(dependencies: JaegerServiceDependency[]): DataFrame[] { + const servicesByName = new Map(); + const edges: Edge[] = []; + + for (const dependency of dependencies) { + addServiceNode(dependency.parent, servicesByName); + addServiceNode(dependency.child, servicesByName); + + edges.push({ + [Fields.id]: dependency.parent + '--' + dependency.child, + [Fields.target]: dependency.child, + [Fields.source]: dependency.parent, + [Fields.mainStat]: dependency.callCount, + }); + } + + const nodesFrame = new MutableDataFrame({ + fields: [ + { name: Fields.id, type: FieldType.string }, + { name: Fields.title, type: FieldType.string }, + ], + meta: { + preferredVisualisationType: 'nodeGraph', + }, + }); + + const edgesFrame = new MutableDataFrame({ + fields: [ + { name: Fields.id, type: FieldType.string }, + { name: Fields.target, type: FieldType.string }, + { name: Fields.source, type: FieldType.string }, + { name: Fields.mainStat, type: FieldType.string, config: { displayName: 'Call count' } }, + ], + meta: { + preferredVisualisationType: 'nodeGraph', + }, + }); + + for (const node of servicesByName.values()) { + nodesFrame.add(node); + } + + for (const edge of edges) { + edgesFrame.add(edge); + } + + return [nodesFrame, edgesFrame]; +} + +/** + * Convenience function to register a service node in the dependency graph. + * @param service Name of the service to register. + * @param servicesByName Map of service nodes keyed name. + */ +function addServiceNode(service: string, servicesByName: Map) { + if (!servicesByName.has(service)) { + servicesByName.set(service, { + [Fields.id]: service, + [Fields.title]: service, + }); + } +} diff --git a/public/app/plugins/datasource/jaeger/types.ts b/public/app/plugins/datasource/jaeger/types.ts index 45ee89fbeb0..fe6778957f6 100644 --- a/public/app/plugins/datasource/jaeger/types.ts +++ b/public/app/plugins/datasource/jaeger/types.ts @@ -63,7 +63,7 @@ export type JaegerQuery = { limit?: number; } & DataQuery; -export type JaegerQueryType = 'search' | 'upload'; +export type JaegerQueryType = 'search' | 'upload' | 'dependencyGraph'; export type JaegerResponse = { data: TraceResponse[]; @@ -72,3 +72,12 @@ export type JaegerResponse = { offset: number; errors?: string[] | null; }; + +/** + * Type definition for service dependencies as returned by the Jaeger dependencies API. + */ +export type JaegerServiceDependency = { + parent: string; + child: string; + callCount: number; +}; From 3979ea0c479a2c562d045584355600908e7e2d8b Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Mon, 15 Jan 2024 12:13:38 +0000 Subject: [PATCH 19/29] Anonymous Access: Pagination for devices (#80028) * first commit * add: pagination to anondevices * fmt * swagger and tests * swagger * testing out test * fixing tests * made it possible to query for from and to time * refactor: change to query for ip adress instead * fix: tests --- .../src/selectors/pages.ts | 3 + .../anonymous/anonimpl/anonstore/database.go | 88 +++++++++++++++++ .../anonymous/anonimpl/anonstore/fake.go | 4 + pkg/services/anonymous/anonimpl/api/api.go | 54 +++++++++++ pkg/services/anonymous/anonimpl/impl.go | 10 ++ pkg/services/anonymous/anonimpl/impl_test.go | 83 ++++++++++++++++ pkg/services/anonymous/sortopts/sortopts.go | 97 +++++++++++++++++++ public/api-merged.json | 82 ++++++++++++++++ .../features/admin/UserListAnonymousPage.tsx | 89 ++++++++++++++++- .../features/admin/Users/AnonUsersTable.tsx | 38 +++++++- public/app/features/admin/state/actions.ts | 75 +++++++++++++- public/app/features/admin/state/reducers.ts | 53 +++++++++- public/app/types/user.ts | 9 ++ public/openapi3.json | 83 ++++++++++++++++ 14 files changed, 752 insertions(+), 16 deletions(-) create mode 100644 pkg/services/anonymous/sortopts/sortopts.go diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index e4064f6bdda..27371fa1522 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -329,6 +329,9 @@ export const Pages = { UsersListPage: { container: 'data-testid users-list-page', }, + UserAnonListPage: { + container: 'data-testid user-anon-list-page', + }, UsersListPublicDashboardsPage: { container: 'data-testid users-list-public-dashboards-page', DashboardsListModal: { diff --git a/pkg/services/anonymous/anonimpl/anonstore/database.go b/pkg/services/anonymous/anonimpl/anonstore/database.go index 8c5ed04db27..34470936217 100644 --- a/pkg/services/anonymous/anonimpl/anonstore/database.go +++ b/pkg/services/anonymous/anonimpl/anonstore/database.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) @@ -32,6 +33,30 @@ type Device struct { UpdatedAt time.Time `json:"updatedAt" xorm:"updated_at" db:"updated_at"` } +type DeviceSearchHitDTO struct { + DeviceID string `json:"deviceId" xorm:"device_id" db:"device_id"` + ClientIP string `json:"clientIp" xorm:"client_ip" db:"client_ip"` + UserAgent string `json:"userAgent" xorm:"user_agent" db:"user_agent"` + CreatedAt time.Time `json:"createdAt" xorm:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" xorm:"updated_at" db:"updated_at"` + LastSeenAt time.Time `json:"lastSeenAt"` +} + +type SearchDeviceQueryResult struct { + TotalCount int64 `json:"totalCount"` + Devices []*DeviceSearchHitDTO `json:"devices"` + Page int `json:"page"` + PerPage int `json:"perPage"` +} +type SearchDeviceQuery struct { + Query string + Page int + Limit int + From time.Time + To time.Time + SortOpts []model.SortOption +} + func (a *Device) CacheKey() string { return strings.Join([]string{cacheKeyPrefix, a.DeviceID}, ":") } @@ -47,6 +72,8 @@ type AnonStore interface { DeleteDevice(ctx context.Context, deviceID string) error // DeleteDevicesOlderThan deletes all devices that have no been updated since the given time. DeleteDevicesOlderThan(ctx context.Context, olderThan time.Time) error + // SearchDevices searches for devices within the 30 days active. + SearchDevices(ctx context.Context, query *SearchDeviceQuery) (*SearchDeviceQueryResult, error) } func ProvideAnonDBStore(sqlStore db.DB, deviceLimit int64) *AnonDBStore { @@ -183,3 +210,64 @@ func (s *AnonDBStore) DeleteDevicesOlderThan(ctx context.Context, olderThan time return err } + +func (s *AnonDBStore) SearchDevices(ctx context.Context, query *SearchDeviceQuery) (*SearchDeviceQueryResult, error) { + result := SearchDeviceQueryResult{ + Devices: make([]*DeviceSearchHitDTO, 0), + } + err := s.sqlStore.WithDbSession(ctx, func(dbSess *db.Session) error { + if query.From.IsZero() && !query.To.IsZero() { + return fmt.Errorf("from date must be set if to date is set") + } + if !query.From.IsZero() && query.To.IsZero() { + return fmt.Errorf("to date must be set if from date is set") + } + + // restricted only to last 30 days, if noting else specified + if query.From.IsZero() && query.To.IsZero() { + query.From = time.Now().Add(-anonymousDeviceExpiration) + query.To = time.Now() + } + + sess := dbSess.Table("anon_device").Alias("d") + + if query.Limit > 0 { + offset := query.Limit * (query.Page - 1) + sess.Limit(query.Limit, offset) + } + sess.Cols("d.id", "d.device_id", "d.client_ip", "d.user_agent", "d.updated_at") + + if len(query.SortOpts) > 0 { + for i := range query.SortOpts { + for j := range query.SortOpts[i].Filter { + sess.OrderBy(query.SortOpts[i].Filter[j].OrderBy()) + } + } + } else { + sess.Asc("d.user_agent") + } + + // add to query about from and to session + sess.Where("d.updated_at BETWEEN ? AND ?", query.From.UTC(), query.To.UTC()) + + if query.Query != "" { + queryWithWildcards := "%" + strings.Replace(query.Query, "\\", "", -1) + "%" + sess.Where("d.client_ip "+s.sqlStore.GetDialect().LikeStr()+" ?", queryWithWildcards) + } + + // get total + devices, err := s.ListDevices(ctx, &query.From, &query.To) + if err != nil { + return err + } + // cast to int64 + result.TotalCount = int64(len(devices)) + if err := sess.Find(&result.Devices); err != nil { + return err + } + result.Page = query.Page + result.PerPage = query.Limit + return nil + }) + return &result, err +} diff --git a/pkg/services/anonymous/anonimpl/anonstore/fake.go b/pkg/services/anonymous/anonimpl/anonstore/fake.go index 9c7249d37bf..8db978d309e 100644 --- a/pkg/services/anonymous/anonimpl/anonstore/fake.go +++ b/pkg/services/anonymous/anonimpl/anonstore/fake.go @@ -19,3 +19,7 @@ func (s *FakeAnonStore) CreateOrUpdateDevice(ctx context.Context, device *Device func (s *FakeAnonStore) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) { return 0, nil } + +func (s *FakeAnonStore) SearchDevices(ctx context.Context, query SearchDeviceQuery) (*SearchDeviceQueryResult, error) { + return nil, nil +} diff --git a/pkg/services/anonymous/anonimpl/api/api.go b/pkg/services/anonymous/anonimpl/api/api.go index defdf28d17a..d545f252deb 100644 --- a/pkg/services/anonymous/anonimpl/api/api.go +++ b/pkg/services/anonymous/anonimpl/api/api.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore" + "github.com/grafana/grafana/pkg/services/anonymous/sortopts" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -50,6 +51,7 @@ func (api *AnonDeviceServiceAPI) RegisterAPIEndpoints() { auth := accesscontrol.Middleware(api.accesscontrol) api.RouterRegister.Group("/api/anonymous", func(anonRoutes routing.RouteRegister) { anonRoutes.Get("/devices", auth(accesscontrol.EvalPermission(accesscontrol.ActionUsersRead)), routing.Wrap(api.ListDevices)) + anonRoutes.Get("/search", auth(accesscontrol.EvalPermission(accesscontrol.ActionUsersRead)), routing.Wrap(api.SearchDevices)) }) } @@ -89,8 +91,60 @@ func (api *AnonDeviceServiceAPI) ListDevices(c *contextmodel.ReqContext) respons return response.JSON(http.StatusOK, resDevices) } +// swagger:route POST /search devices SearchDevices +// +// # Lists all devices within the last 30 days +// +// Produces: +// - application/json +// +// Responses: +// +// 200: devicesSearchResponse +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError +func (api *AnonDeviceServiceAPI) SearchDevices(c *contextmodel.ReqContext) response.Response { + perPage := c.QueryInt("perpage") + if perPage <= 0 { + perPage = 100 + } + page := c.QueryInt("page") + + if page < 1 { + page = 1 + } + + searchQuery := c.Query("query") + + sortOpts, err := sortopts.ParseSortQueryParam(c.Query("sort")) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to list devices", err) + } + + // TODO: potential add from and to time to query + query := &anonstore.SearchDeviceQuery{ + Query: searchQuery, + Page: page, + Limit: perPage, + SortOpts: sortOpts, + } + results, err := api.store.SearchDevices(c.Req.Context(), query) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to list devices", err) + } + return response.JSON(http.StatusOK, results) +} + // swagger:response devicesResponse type DevicesResponse struct { // in:body Body []deviceDTO `json:"body"` } + +// swagger:response devicesSearchResponse +type DevicesSearchResponse struct { + // in:body + Body anonstore.SearchDeviceQueryResult `json:"body"` +} diff --git a/pkg/services/anonymous/anonimpl/impl.go b/pkg/services/anonymous/anonimpl/impl.go index 826be4d0a90..8a7e0ac6586 100644 --- a/pkg/services/anonymous/anonimpl/impl.go +++ b/pkg/services/anonymous/anonimpl/impl.go @@ -154,6 +154,7 @@ func (a *AnonDeviceService) TagDevice(ctx context.Context, httpReq *http.Request // ListDevices returns all devices that have been updated between the given times. func (a *AnonDeviceService) ListDevices(ctx context.Context, from *time.Time, to *time.Time) ([]*anonstore.Device, error) { if !a.cfg.AnonymousEnabled { + a.log.Debug("Anonymous access is disabled, returning empty result") return []*anonstore.Device{}, nil } @@ -163,12 +164,21 @@ func (a *AnonDeviceService) ListDevices(ctx context.Context, from *time.Time, to // CountDevices returns the number of devices that have been updated between the given times. func (a *AnonDeviceService) CountDevices(ctx context.Context, from time.Time, to time.Time) (int64, error) { if !a.cfg.AnonymousEnabled { + a.log.Debug("Anonymous access is disabled, returning empty result") return 0, nil } return a.anonStore.CountDevices(ctx, from, to) } +func (a *AnonDeviceService) SearchDevices(ctx context.Context, query *anonstore.SearchDeviceQuery) (*anonstore.SearchDeviceQueryResult, error) { + if !a.cfg.AnonymousEnabled { + a.log.Debug("Anonymous access is disabled, returning empty result") + return nil, nil + } + return a.anonStore.SearchDevices(ctx, query) +} + func (a *AnonDeviceService) Run(ctx context.Context) error { ticker := time.NewTicker(2 * time.Hour) diff --git a/pkg/services/anonymous/anonimpl/impl_test.go b/pkg/services/anonymous/anonimpl/impl_test.go index 295654f93bc..37524473545 100644 --- a/pkg/services/anonymous/anonimpl/impl_test.go +++ b/pkg/services/anonymous/anonimpl/impl_test.go @@ -177,3 +177,86 @@ func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) { assert.Equal(t, int64(0), stats["stats.anonymous.device.ui.count"].(int64)) } + +func TestIntegrationDeviceService_SearchDevice(t *testing.T) { + testCases := []struct { + name string + insertDevices []*anonstore.Device + searchQuery anonstore.SearchDeviceQuery + expectedCount int + expectedDevice *anonstore.Device + }{ + { + name: "two devices and limit set to 1", + insertDevices: []*anonstore.Device{ + { + DeviceID: "32mdo31deeqwes", + ClientIP: "", + UserAgent: "test", + UpdatedAt: time.Now().UTC(), + }, + { + DeviceID: "32mdo31deeqwes2", + ClientIP: "", + UserAgent: "test2", + UpdatedAt: time.Now().UTC(), + }, + }, + searchQuery: anonstore.SearchDeviceQuery{ + Query: "", + Page: 1, + Limit: 1, + }, + expectedCount: 1, + }, + { + name: "two devices and search for client ip 192.1", + insertDevices: []*anonstore.Device{ + { + DeviceID: "32mdo31deeqwes", + ClientIP: "192.168.0.2:10", + UserAgent: "", + UpdatedAt: time.Now().UTC(), + }, + { + DeviceID: "32mdo31deeqwes2", + ClientIP: "192.268.1.3:200", + UserAgent: "", + UpdatedAt: time.Now().UTC(), + }, + }, + searchQuery: anonstore.SearchDeviceQuery{ + Query: "192.1", + Page: 1, + Limit: 50, + }, + expectedCount: 1, + expectedDevice: &anonstore.Device{ + DeviceID: "32mdo31deeqwes", + ClientIP: "192.168.0.2:10", + UserAgent: "", + UpdatedAt: time.Now().UTC(), + }, + }, + } + store := db.InitTestDB(t) + anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{}, + &authntest.FakeService{}, store, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{}) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, device := range tc.insertDevices { + err := anonService.anonStore.CreateOrUpdateDevice(context.Background(), device) + require.NoError(t, err) + } + + devices, err := anonService.anonStore.SearchDevices(context.Background(), &tc.searchQuery) + require.NoError(t, err) + require.Len(t, devices.Devices, tc.expectedCount) + if tc.expectedDevice != nil { + device := devices.Devices[0] + require.Equal(t, tc.expectedDevice.UserAgent, device.UserAgent) + } + }) + } +} diff --git a/pkg/services/anonymous/sortopts/sortopts.go b/pkg/services/anonymous/sortopts/sortopts.go new file mode 100644 index 00000000000..4ebb16e4929 --- /dev/null +++ b/pkg/services/anonymous/sortopts/sortopts.go @@ -0,0 +1,97 @@ +package sortopts + +import ( + "fmt" + "sort" + "strings" + + "github.com/grafana/grafana/pkg/services/search/model" + "github.com/grafana/grafana/pkg/util/errutil" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var ( + // SortOptionsByQueryParam is a map to translate the "sort" query param values to SortOption(s) + SortOptionsByQueryParam = map[string]model.SortOption{ + "userAgent-asc": newSortOption("user_agent", false, 0), + "userAgent-desc": newSortOption("user_agent", true, 0), + "updatedAt-asc": newTimeSortOption("updated_at", false, 1), + "updatedAt-desc": newTimeSortOption("updated_at", true, 2), + } + + ErrorUnknownSortingOption = errutil.BadRequest("unknown sorting option") +) + +type Sorter struct { + Field string + LowerCase bool + Descending bool + WithTableName bool +} + +func (s Sorter) OrderBy() string { + orderBy := "anon_device." + if !s.WithTableName { + orderBy = "" + } + orderBy += s.Field + if s.LowerCase { + orderBy = fmt.Sprintf("LOWER(%v)", orderBy) + } + if s.Descending { + return orderBy + " DESC" + } + return orderBy + " ASC" +} + +func newSortOption(field string, desc bool, index int) model.SortOption { + direction := "asc" + description := ("A-Z") + if desc { + direction = "desc" + description = ("Z-A") + } + return model.SortOption{ + Name: fmt.Sprintf("%v-%v", field, direction), + DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description), + Description: fmt.Sprintf("Sort %v in an alphabetically %vending order", field, direction), + Index: index, + Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}}, + } +} + +func newTimeSortOption(field string, desc bool, index int) model.SortOption { + direction := "asc" + description := ("Oldest-Newest") + if desc { + direction = "desc" + description = ("Newest-Oldest") + } + return model.SortOption{ + Name: fmt.Sprintf("%v-%v", field, direction), + DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description), + Description: fmt.Sprintf("Sort %v by time in an %vending order", field, direction), + Index: index, + Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}}, + } +} + +// ParseSortQueryParam parses the "sort" query param and returns an ordered list of SortOption(s) +func ParseSortQueryParam(param string) ([]model.SortOption, error) { + opts := []model.SortOption{} + if param != "" { + optsStr := strings.Split(param, ",") + for i := range optsStr { + if opt, ok := SortOptionsByQueryParam[optsStr[i]]; !ok { + return nil, ErrorUnknownSortingOption.Errorf("%v option unknown", optsStr[i]) + } else { + opts = append(opts, opt) + } + } + sort.Slice(opts, func(i, j int) bool { + return opts[i].Index < opts[j].Index || (opts[i].Index == opts[j].Index && opts[i].Name < opts[j].Name) + }) + } + return opts, nil +} diff --git a/public/api-merged.json b/public/api-merged.json index c199a3282e4..34824587b77 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -9452,6 +9452,33 @@ "$ref": "#/responses/internalServerError" } } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "devices" + ], + "summary": "Lists all devices within the last 30 days", + "operationId": "SearchDevices", + "responses": { + "200": { + "$ref": "#/responses/devicesSearchResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } } }, "/search/sorting": { @@ -14263,6 +14290,32 @@ } } }, + "DeviceSearchHitDTO": { + "type": "object", + "properties": { + "clientIp": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "deviceId": { + "type": "string" + }, + "lastSeenAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "userAgent": { + "type": "string" + } + } + }, "DiscordConfig": { "type": "object", "title": "DiscordConfig configures notifications via Discord.", @@ -19229,6 +19282,29 @@ } } }, + "SearchDeviceQueryResult": { + "type": "object", + "properties": { + "devices": { + "type": "array", + "items": { + "$ref": "#/definitions/DeviceSearchHitDTO" + } + }, + "page": { + "type": "integer", + "format": "int64" + }, + "perPage": { + "type": "integer", + "format": "int64" + }, + "totalCount": { + "type": "integer", + "format": "int64" + } + } + }, "SearchOrgServiceAccountsResult": { "description": "swagger: model", "type": "object", @@ -22431,6 +22507,12 @@ } } }, + "devicesSearchResponse": { + "description": "(empty)", + "schema": { + "$ref": "#/definitions/SearchDeviceQueryResult" + } + }, "folderResponse": { "description": "(empty)", "schema": { diff --git a/public/app/features/admin/UserListAnonymousPage.tsx b/public/app/features/admin/UserListAnonymousPage.tsx index a452fa5a90a..3d4355653a9 100644 --- a/public/app/features/admin/UserListAnonymousPage.tsx +++ b/public/app/features/admin/UserListAnonymousPage.tsx @@ -1,35 +1,85 @@ +import { css } from '@emotion/css'; import React, { useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { RadioButtonGroup, useStyles2, FilterInput } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { StoreState } from '../../types'; import { AnonUsersDevicesTable } from './Users/AnonUsersTable'; -import { fetchUsersAnonymousDevices } from './state/actions'; +import { fetchUsersAnonymousDevices, changeAnonUserSort, changeAnonPage, changeAnonQuery } from './state/actions'; const mapDispatchToProps = { fetchUsersAnonymousDevices, + changeAnonUserSort, + changeAnonPage, + changeAnonQuery, }; const mapStateToProps = (state: StoreState) => ({ devices: state.userListAnonymousDevices.devices, + query: state.userListAnonymousDevices.query, + showPaging: state.userListAnonymousDevices.showPaging, + totalPages: state.userListAnonymousDevices.totalPages, + page: state.userListAnonymousDevices.page, + filters: state.userListAnonymousDevices.filters, }); +const selectors = e2eSelectors.pages.UserListPage.UserListAdminPage; + const connector = connect(mapStateToProps, mapDispatchToProps); interface OwnProps {} type Props = OwnProps & ConnectedProps; -const UserListAnonymousDevicesPageUnConnected = ({ devices, fetchUsersAnonymousDevices }: Props) => { +const UserListAnonymousDevicesPageUnConnected = ({ + devices, + fetchUsersAnonymousDevices, + query, + changeAnonQuery, + filters, + showPaging, + totalPages, + page, + changeAnonPage, + changeAnonUserSort, +}: Props) => { + const styles = useStyles2(getStyles); + useEffect(() => { fetchUsersAnonymousDevices(); }, [fetchUsersAnonymousDevices]); return ( - +
+
+ + changeFilter({ name: 'activeLast30Days', value })} + value={filters.find((f) => f.name === 'activeLast30Days')?.value} + className={styles.filter} + /> +
+
+
); }; @@ -44,4 +94,37 @@ export function UserListAnonymousDevicesPage() { ); } +const getStyles = (theme: GrafanaTheme2) => { + return { + filter: css({ + margin: theme.spacing(0, 1), + [theme.breakpoints.down('sm')]: { + margin: 0, + }, + }), + actionBar: css({ + marginBottom: theme.spacing(2), + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(2), + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + }, + }), + row: css({ + display: 'flex', + alignItems: 'flex-start', + textAlign: 'left', + marginBottom: theme.spacing(0.5), + flexGrow: 1, + + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + gap: theme.spacing(2), + width: '100%', + }, + }), + }; +}; + export default UserListAnonymousDevicesPage; diff --git a/public/app/features/admin/Users/AnonUsersTable.tsx b/public/app/features/admin/Users/AnonUsersTable.tsx index f2ecd65c5a1..75c7f96aa78 100644 --- a/public/app/features/admin/Users/AnonUsersTable.tsx +++ b/public/app/features/admin/Users/AnonUsersTable.tsx @@ -1,6 +1,16 @@ import React, { useMemo } from 'react'; -import { Avatar, CellProps, Column, InteractiveTable, Stack, Badge, Tooltip } from '@grafana/ui'; +import { + Avatar, + CellProps, + Column, + InteractiveTable, + Stack, + Badge, + Tooltip, + Pagination, + FetchDataFunc, +} from '@grafana/ui'; import { EmptyArea } from 'app/features/alerting/unified/components/EmptyArea'; import { UserAnonymousDeviceDTO } from 'app/types'; @@ -49,9 +59,22 @@ const UserAgentCell = ({ value }: UserAgentCellProps) => { interface AnonUsersTableProps { devices: UserAnonymousDeviceDTO[]; + // for pagination + showPaging?: boolean; + totalPages: number; + onChangePage: (page: number) => void; + currentPage: number; + fetchData?: FetchDataFunc; } -export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => { +export const AnonUsersDevicesTable = ({ + devices, + showPaging, + totalPages, + onChangePage, + currentPage, + fetchData, +}: AnonUsersTableProps) => { const columns: Array> = useMemo( () => [ { @@ -71,9 +94,9 @@ export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => { sortType: 'string', }, { - id: 'lastSeenAt', + id: 'updatedAt', header: 'Last active', - cell: ({ cell: { value } }: Cell<'lastSeenAt'>) => value, + cell: ({ cell: { value } }: Cell<'updatedAt'>) => value, sortType: (a, b) => new Date(a.original.updatedAt).getTime() - new Date(b.original.updatedAt).getTime(), }, { @@ -86,7 +109,12 @@ export const AnonUsersDevicesTable = ({ devices }: AnonUsersTableProps) => { ); return ( - user.deviceId} /> + user.deviceId} fetchData={fetchData} /> + {showPaging && ( + + + + )} {devices.length === 0 && ( No anonymous users found. diff --git a/public/app/features/admin/state/actions.ts b/public/app/features/admin/state/actions.ts index eaca44d51cd..e83e9f188fe 100644 --- a/public/app/features/admin/state/actions.ts +++ b/public/app/features/admin/state/actions.ts @@ -6,7 +6,15 @@ import { FetchDataArgs } from '@grafana/ui'; import config from 'app/core/config'; import { contextSrv } from 'app/core/core'; import { accessControlQueryParam } from 'app/core/utils/accessControl'; -import { ThunkResult, LdapUser, UserSession, UserDTO, AccessControlAction, UserFilter } from 'app/types'; +import { + ThunkResult, + LdapUser, + UserSession, + UserDTO, + AccessControlAction, + UserFilter, + AnonUserFilter, +} from 'app/types'; import { userAdminPageLoadedAction, @@ -29,6 +37,9 @@ import { usersFetchEnd, sortChanged, usersAnonymousDevicesFetched, + anonUserSortChanged, + anonPageChanged, + anonQueryChanged, } from './reducers'; // UserAdminPage @@ -337,16 +348,72 @@ export function changeSort({ sortBy }: FetchDataArgs): ThunkResult { + return filters + .map((filter) => { + if (Array.isArray(filter.value)) { + return filter.value.map((v) => `${filter.name}=${v.value}`).join('&'); + } + return `${filter.name}=${filter.value}`; + }) + .join('&'); +}; export function fetchUsersAnonymousDevices(): ThunkResult { return async (dispatch, getState) => { try { - let url = `/api/anonymous/devices`; + const { perPage, page, query, filters, sort } = getState().userListAnonymousDevices; + let url = `/api/anonymous/search?perpage=${perPage}&page=${page}&query=${query}&${getAnonFilters(filters)}`; + if (sort) { + url += `&sort=${sort}`; + } const result = await getBackendSrv().get(url); - dispatch(usersAnonymousDevicesFetched({ devices: result })); + dispatch(usersAnonymousDevicesFetched(result)); } catch (error) { - usersFetchEnd(); console.error(error); } }; } + +const fetchAnonUsersWithDebounce = debounce((dispatch) => dispatch(fetchUsersAnonymousDevices()), 500); + +export function changeAnonUserSort({ sortBy }: FetchDataArgs): ThunkResult { + const sort = sortBy.length ? `${sortBy[0].id}-${sortBy[0].desc ? 'desc' : 'asc'}` : undefined; + return async (dispatch, getState) => { + const currentSort = getState().userListAnonymousDevices.sort; + if (currentSort !== sort) { + // dispatch(usersFetchBegin()); + dispatch(anonUserSortChanged(sort)); + dispatch(fetchUsersAnonymousDevices()); + } + }; +} + +export function changeAnonQuery(query: string): ThunkResult { + return async (dispatch) => { + // dispatch(usersFetchBegin()); + dispatch(anonQueryChanged(query)); + fetchAnonUsersWithDebounce(dispatch); + }; +} + +export function changeAnonPage(page: number): ThunkResult { + return async (dispatch) => { + // dispatch(usersFetchBegin()); + dispatch(anonPageChanged(page)); + dispatch(fetchUsersAnonymousDevices()); + }; +} + +// export function fetchUsersAnonymousDevices(): ThunkResult { +// return async (dispatch, getState) => { +// try { +// let url = `/api/anonymous/devices`; +// const result = await getBackendSrv().get(url); +// dispatch(usersAnonymousDevicesFetched({ devices: result })); +// } catch (error) { +// usersFetchEnd(); +// console.error(error); +// } +// }; +// } diff --git a/public/app/features/admin/state/reducers.ts b/public/app/features/admin/state/reducers.ts index 68af1117cbf..550e3de57b6 100644 --- a/public/app/features/admin/state/reducers.ts +++ b/public/app/features/admin/state/reducers.ts @@ -15,6 +15,7 @@ import { UserFilter, UserListAnonymousDevicesState, UserAnonymousDeviceDTO, + AnonUserFilter, } from 'app/types'; const initialLdapState: LdapState = { @@ -207,10 +208,19 @@ export const userListAdminReducer = userListAdminSlice.reducer; const initialUserListAnonymousDevicesState: UserListAnonymousDevicesState = { devices: [], + query: '', + page: 0, + perPage: 50, + totalPages: 1, + showPaging: false, + filters: [{ name: 'activeLast30Days', value: true }], }; interface UsersAnonymousDevicesFetched { devices: UserAnonymousDeviceDTO[]; + perPage: number; + page: number; + totalCount: number; } export const userListAnonymousDevicesSlice = createSlice({ @@ -218,17 +228,52 @@ export const userListAnonymousDevicesSlice = createSlice({ initialState: initialUserListAnonymousDevicesState, reducers: { usersAnonymousDevicesFetched: (state, action: PayloadAction) => { - const { devices } = action.payload; + const { totalCount, perPage, ...rest } = action.payload; + const totalPages = Math.ceil(totalCount / perPage); + return { ...state, - devices, - isLoading: false, + ...rest, + totalPages, + perPage, + showPaging: totalPages > 1, + }; + }, + anonQueryChanged: (state, action: PayloadAction) => ({ + ...state, + query: action.payload, + page: 0, + }), + anonPageChanged: (state, action: PayloadAction) => ({ + ...state, + page: action.payload, + }), + anonUserSortChanged: (state, action: PayloadAction) => ({ + ...state, + page: 0, + sort: action.payload, + }), + filterChanged: (state, action: PayloadAction) => { + const { name, value } = action.payload; + + if (state.filters.some((filter) => filter.name === name)) { + return { + ...state, + page: 0, + filters: state.filters.map((filter) => (filter.name === name ? { ...filter, value } : filter)), + }; + } + return { + ...state, + page: 0, + filters: [...state.filters, action.payload], }; }, }, }); -export const { usersAnonymousDevicesFetched } = userListAnonymousDevicesSlice.actions; +export const { usersAnonymousDevicesFetched, anonUserSortChanged, anonPageChanged, anonQueryChanged } = + userListAnonymousDevicesSlice.actions; export const userListAnonymousDevicesReducer = userListAnonymousDevicesSlice.reducer; export default { diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 1c300befa1a..8731658c6f5 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -142,6 +142,15 @@ export interface UserAnonymousDeviceDTO { avatarUrl?: string; } +export type AnonUserFilter = Record; + export interface UserListAnonymousDevicesState { devices: UserAnonymousDeviceDTO[]; + query: string; + perPage: number; + page: number; + totalPages: number; + showPaging: boolean; + filters: AnonUserFilter[]; + sort?: string; } diff --git a/public/openapi3.json b/public/openapi3.json index a791047b042..f924394f4ad 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -519,6 +519,16 @@ }, "description": "(empty)" }, + "devicesSearchResponse": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchDeviceQueryResult" + } + } + }, + "description": "(empty)" + }, "folderResponse": { "content": { "application/json": { @@ -4794,6 +4804,32 @@ }, "type": "object" }, + "DeviceSearchHitDTO": { + "properties": { + "clientIp": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "lastSeenAt": { + "format": "date-time", + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "userAgent": { + "type": "string" + } + }, + "type": "object" + }, "DiscordConfig": { "properties": { "http_config": { @@ -9759,6 +9795,29 @@ }, "type": "object" }, + "SearchDeviceQueryResult": { + "properties": { + "devices": { + "items": { + "$ref": "#/components/schemas/DeviceSearchHitDTO" + }, + "type": "array" + }, + "page": { + "format": "int64", + "type": "integer" + }, + "perPage": { + "format": "int64", + "type": "integer" + }, + "totalCount": { + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, "SearchOrgServiceAccountsResult": { "description": "swagger: model", "properties": { @@ -22921,6 +22980,30 @@ "tags": [ "search" ] + }, + "post": { + "operationId": "SearchDevices", + "responses": { + "200": { + "$ref": "#/components/responses/devicesSearchResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Lists all devices within the last 30 days", + "tags": [ + "devices" + ] } }, "/search/sorting": { From 2a4f6ba5b025cbbf3571b7f5f24233e1cc59f547 Mon Sep 17 00:00:00 2001 From: Giordano Ricci Date: Mon, 15 Jan 2024 12:26:34 +0000 Subject: [PATCH 20/29] Explore: skip flaky e2e test (#80543) --- e2e/various-suite/explore.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/various-suite/explore.spec.ts b/e2e/various-suite/explore.spec.ts index 0207aa5d82a..3b2f053f201 100644 --- a/e2e/various-suite/explore.spec.ts +++ b/e2e/various-suite/explore.spec.ts @@ -1,6 +1,7 @@ import { e2e } from '../utils'; -describe('Explore', () => { +// This seems to be flaky on CI, skipping it for now. +describe.skip('Explore', () => { beforeEach(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); }); From 9f0bb9cb07c829b8c3dc50feb32912631bb82d55 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 12:08:22 +0000 Subject: [PATCH 21/29] Update dependency @grafana/faro-core to v1.3.6 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 1a6dd567db2..c499b77d715 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3285,13 +3285,13 @@ __metadata: linkType: hard "@grafana/faro-core@npm:^1.3.5": - version: 1.3.5 - resolution: "@grafana/faro-core@npm:1.3.5" + version: 1.3.6 + resolution: "@grafana/faro-core@npm:1.3.6" dependencies: "@opentelemetry/api": "npm:^1.7.0" "@opentelemetry/otlp-transformer": "npm:^0.45.1" murmurhash-js: "npm:^1.0.0" - checksum: 342032230b5f6e9c38fe84abf73b5404dc417a0e164221d22d10a0f473de6a8c9b0021e4b338613f61c1e8f69dfc4ea584577f5c58232b79bca236725da4ad21 + checksum: 133dd0fa4a11effc0bc41d89ebcf341e1541866bfb7e692225365f556e62ee6de4b6793acf2622e0cad2971c59b0a3e20144606d3f75151597d4ef3f52d4b51f languageName: node linkType: hard From 10f0d094ad031ba6dc1a720ac76bb42cde5dd442 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon, 15 Jan 2024 13:36:26 +0100 Subject: [PATCH 22/29] Alerting: Visualize autogenerated policy tree for simplified routing. (#79509) * WIP * WIP: disable some actions when is autogenerated policy * WIP * Wip: add checks for group by in auto-generated policy * Make autogenerated policy readOnly and enable Readonly modal for it * Use real check for autogenerated root * Fix test * Refactor: rename consts * Add test for policy form being read only * Add tests * Update some code comments * Fix Switch component not being styled as disabled * Rename isAutogeneratedChunkOpen property to isBranchOpen and fix test * Revert fix for Switch as it has moved to another separate PR * Split Policy component in smaller sub components * use useAlertmanagerAbility form for checking autogenerated tree visibility and fix container for autogenerated policy being rendered when it's not supported * Update useAbilities test and dont use toAbility for ViewAutogeneratedPolicyTree * Fix Policy being unmounted every 10 secs and move the collapsed/expanded state to each Policy component * remove permissions from createDropdownMenuActions method parameters and convert the method to a hook * Revert using PolicyItem * Add test for createDropdownMenuActions * Revert having a read only view form for the policy * Remove readonly from default policy form * Only show collapsible when node has children * Split DefaultPolicyIndicator * use hidehideCurrentPolicy instead of showCurrentPolicy * Address some review suggestions --- .betterer.results | 10 +- .../alerting/unified/NotificationPolicies.tsx | 9 +- .../EditNotificationPolicyForm.tsx | 1 + .../notification-policies/Policy.test.tsx | 113 ++- .../notification-policies/Policy.tsx | 751 +++++++++++------- .../__snapshots__/useAbilities.test.tsx.snap | 12 + .../alerting/unified/hooks/useAbilities.ts | 7 +- 7 files changed, 588 insertions(+), 315 deletions(-) diff --git a/.betterer.results b/.betterer.results index 0ac51d62848..16780915d45 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1659,15 +1659,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "1"] ], "public/app/features/alerting/unified/components/notification-policies/Policy.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/alerting/unified/components/notification-policies/PromDurationDocs.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], diff --git a/public/app/features/alerting/unified/NotificationPolicies.tsx b/public/app/features/alerting/unified/NotificationPolicies.tsx index 8c66e0222d2..be19ca4ed14 100644 --- a/public/app/features/alerting/unified/NotificationPolicies.tsx +++ b/public/app/features/alerting/unified/NotificationPolicies.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useAsyncFn } from 'react-use'; import { GrafanaTheme2, UrlQueryMap } from '@grafana/data'; -import { Alert, LoadingPlaceholder, Tab, TabContent, TabsBar, useStyles2, withErrorBoundary, Stack } from '@grafana/ui'; +import { Alert, LoadingPlaceholder, Stack, Tab, TabContent, TabsBar, useStyles2, withErrorBoundary } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types'; @@ -16,12 +16,12 @@ import { useGetContactPointsState } from './api/receiversApi'; import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning'; import { MuteTimingsTable } from './components/mute-timings/MuteTimingsTable'; -import { findRoutesMatchingPredicate, NotificationPoliciesFilter } from './components/notification-policies/Filters'; +import { NotificationPoliciesFilter, findRoutesMatchingPredicate } from './components/notification-policies/Filters'; import { useAddPolicyModal, - useEditPolicyModal, - useDeletePolicyModal, useAlertGroupsModal, + useDeletePolicyModal, + useEditPolicyModal, } from './components/notification-policies/Modals'; import { Policy } from './components/notification-policies/Policy'; import { useAlertmanagerConfig } from './hooks/useAlertmanagerConfig'; @@ -249,6 +249,7 @@ const AmRoutes = () => { onShowAlertInstances={showAlertGroupsModal} routesMatchingFilters={routesMatchingFilters} matchingInstancesPreview={{ groupsMap: routeAlertGroupsMap, enabled: !instancesPreviewError }} + isAutoGenerated={false} /> )} diff --git a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx index 842c90c52db..f7eabb4896d 100644 --- a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx @@ -322,6 +322,7 @@ const getStyles = (theme: GrafanaTheme2) => { `, noMatchersWarning: css` padding: ${theme.spacing(1)} ${theme.spacing(2)}; + margin-bottom: ${theme.spacing(1)}; `, }; }; diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx index 88fece21e9f..88e43ab5a71 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx @@ -1,10 +1,10 @@ -import { render, screen, within } from '@testing-library/react'; +import { render, renderHook, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { noop } from 'lodash'; import React from 'react'; import { Router } from 'react-router-dom'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; import { AlertmanagerGroup, @@ -19,7 +19,12 @@ import { mockAlertGroup, mockAlertmanagerAlert, mockReceiversState } from '../.. import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; -import { Policy } from './Policy'; +import { + AUTOGENERATED_ROOT_LABEL_NAME, + Policy, + isAutoGeneratedRootAndSimplifiedEnabled, + useCreateDropdownMenuActions, +} from './Policy'; jest.mock('../../hooks/useAbilities', () => ({ ...jest.requireActual('../../hooks/useAbilities'), @@ -163,6 +168,7 @@ describe('Policy', () => { onAddPolicy={onAddPolicy} onDeletePolicy={onDeletePolicy} onShowAlertInstances={onShowAlertInstances} + isAutoGenerated={false} /> ); // should have default policy @@ -379,3 +385,104 @@ const mockRoutes: RouteWithID = { group_interval: undefined, repeat_interval: undefined, }; + +describe('isAutoGeneratedRootAndSimplifiedEnabled', () => { + it('returns false when simplified routing is not enabled', () => { + const route: RouteWithID = { + id: '1', + object_matchers: [['label', MatcherOperator.equal, 'true']], + }; + config.featureToggles.alertingSimplifiedRouting = false; + expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(false); + }); + + it('returns false when object_matchers is not defined', () => { + const route: RouteWithID = { + id: '1', + }; + config.featureToggles.alertingSimplifiedRouting = true; + expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(false); + }); + + it('returns true when object_matchers contains AUTOGENERATED_ROOT_LABEL_NAME, and simplified routing is enabled', () => { + const route: RouteWithID = { + id: '1', + object_matchers: [[AUTOGENERATED_ROOT_LABEL_NAME, MatcherOperator.equal, 'true']], + }; + config.featureToggles.alertingSimplifiedRouting = true; + expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(true); + }); + + it('returns false when object_matchers does not contain AUTOGENERATED_ROOT_LABEL_NAME, and simplified routing is enabled', () => { + const route: RouteWithID = { + id: '1', + object_matchers: [['label', MatcherOperator.equal, 'true']], + }; + config.featureToggles.alertingSimplifiedRouting = true; + expect(isAutoGeneratedRootAndSimplifiedEnabled(route)).toBe(false); + }); +}); + +describe('useCreateDropdownMenuActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const openDetailModal = jest.fn(); + const currentRoute: RouteWithID = { id: '0', routes: [{ id: '1' }] }; + const toggleShowExportDrawer = jest.fn(); + const onDeletePolicy = jest.fn(); + const testCases = [ + { + isAutoGenerated: false, + isDefaultPolicy: true, + provisioned: false, + expectedMenu: ['edit-policy', 'export-policy'], + }, + { + isAutoGenerated: false, + isDefaultPolicy: true, + provisioned: true, + expectedMenu: ['edit-policy', 'export-policy'], + }, + { + isAutoGenerated: false, + isDefaultPolicy: false, + provisioned: false, + expectedMenu: ['edit-policy', 'delete-policy'], + }, + { + isAutoGenerated: false, + isDefaultPolicy: false, + provisioned: true, + expectedMenu: ['edit-policy', 'delete-policy'], + }, + { isAutoGenerated: true, isDefaultPolicy: true, provisioned: true, expectedMenu: ['edit-policy'] }, + { isAutoGenerated: true, isDefaultPolicy: false, provisioned: false, expectedMenu: ['edit-policy'] }, + { isAutoGenerated: true, isDefaultPolicy: true, provisioned: false, expectedMenu: ['edit-policy'] }, + { isAutoGenerated: true, isDefaultPolicy: false, provisioned: true, expectedMenu: ['edit-policy'] }, + ]; + + testCases.forEach(({ isAutoGenerated, isDefaultPolicy, provisioned, expectedMenu }) => { + it(`Having all the permissions returns ${expectedMenu.length} menu items for isAutoGenerated=${isAutoGenerated}, isDefaultPolicy=${isDefaultPolicy}, provisioned=${provisioned}`, () => { + useAlertmanagerAbilitiesMock.mockReturnValue([ + [true, true], + [true, true], + [true, true], + ]); + const { result } = renderHook(() => + useCreateDropdownMenuActions( + isAutoGenerated, + isDefaultPolicy, + provisioned, + openDetailModal, + currentRoute, + toggleShowExportDrawer, + onDeletePolicy + ) + ); + + const menuItemsKeys = result.current.map((item) => item.key ?? ''); + expect(menuItemsKeys).toEqual(expectedMenu); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx index d5e766bc342..d4cb411e1bc 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -6,11 +6,13 @@ import { Link } from 'react-router-dom'; import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Badge, Button, Dropdown, Icon, + IconButton, Menu, Stack, Text, @@ -19,14 +21,20 @@ import { useStyles2, } from '@grafana/ui'; import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap'; -import { AlertmanagerGroup, ObjectMatcher, Receiver, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; +import { + AlertmanagerGroup, + MatcherOperator, + ObjectMatcher, + Receiver, + RouteWithID, +} from 'app/plugins/datasource/alertmanager/types'; import { ReceiversState } from 'app/types'; -import { AlertmanagerAction, useAlertmanagerAbilities } from '../../hooks/useAbilities'; +import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { INTEGRATION_ICONS } from '../../types/contact-points'; import { normalizeMatchers } from '../../utils/matchers'; import { createContactPointLink, createMuteTimingLink } from '../../utils/misc'; -import { getInheritedProperties, InhertitableProperties } from '../../utils/notification-policies'; +import { InhertitableProperties, getInheritedProperties } from '../../utils/notification-policies'; import { Authorize } from '../Authorize'; import { HoverCard } from '../HoverCard'; import { Label } from '../Label'; @@ -37,7 +45,7 @@ import { Strong } from '../Strong'; import { GrafanaPoliciesExporter } from '../export/GrafanaPoliciesExporter'; import { Matchers } from './Matchers'; -import { TimingOptions, TIMING_OPTIONS_DEFAULTS } from './timingOptions'; +import { TIMING_OPTIONS_DEFAULTS, TimingOptions } from './timingOptions'; interface PolicyComponentProps { receivers?: Receiver[]; @@ -54,55 +62,42 @@ interface PolicyComponentProps { routeTree: RouteWithID; currentRoute: RouteWithID; alertManagerSourceName: string; - onEditPolicy: (route: RouteWithID, isDefault?: boolean) => void; + onEditPolicy: (route: RouteWithID, isDefault?: boolean, isAutogenerated?: boolean) => void; onAddPolicy: (route: RouteWithID) => void; onDeletePolicy: (route: RouteWithID) => void; onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void; + isAutoGenerated?: boolean; } -const Policy: FC = ({ - receivers = [], - contactPointsState, - readOnly = false, - provisioned = false, - alertGroups = [], - alertManagerSourceName, - currentRoute, - routeTree, - inheritedProperties, - routesMatchingFilters = [], - matchingInstancesPreview = { enabled: false }, - onEditPolicy, - onAddPolicy, - onDeletePolicy, - onShowAlertInstances, -}) => { - const styles = useStyles2(getStyles); - const isDefaultPolicy = currentRoute === routeTree; +const Policy = (props: PolicyComponentProps) => { + const { + receivers = [], + contactPointsState, + readOnly = false, + provisioned = false, + alertGroups = [], + alertManagerSourceName, + currentRoute, + routeTree, + inheritedProperties, + routesMatchingFilters = [], + matchingInstancesPreview = { enabled: false }, + onEditPolicy, + onAddPolicy, + onDeletePolicy, + onShowAlertInstances, + isAutoGenerated = false, + } = props; - const [ - [updatePoliciesSupported, updatePoliciesAllowed], - [deletePolicySupported, deletePolicyAllowed], - [exportPoliciesSupported, exportPoliciesAllowed], - ] = useAlertmanagerAbilities([ - AlertmanagerAction.UpdateNotificationPolicyTree, - AlertmanagerAction.DeleteNotificationPolicy, - AlertmanagerAction.ExportNotificationPolicies, - ]); + const styles = useStyles2(getStyles); + + const isDefaultPolicy = currentRoute === routeTree; const contactPoint = currentRoute.receiver; const continueMatching = currentRoute.continue ?? false; - const groupBy = currentRoute.group_by; - const muteTimings = currentRoute.mute_time_intervals ?? []; - const timingOptions: TimingOptions = { - group_wait: currentRoute.group_wait, - group_interval: currentRoute.group_interval, - repeat_interval: currentRoute.repeat_interval, - }; const matchers = normalizeMatchers(currentRoute); const hasMatchers = Boolean(matchers && matchers.length); - const hasMuteTimings = Boolean(muteTimings.length); const hasFocus = routesMatchingFilters.some((route) => route.id === currentRoute.id); // gather errors here @@ -116,33 +111,340 @@ const Policy: FC = ({ const actualContactPoint = contactPoint ?? inheritedProperties?.receiver ?? ''; const contactPointErrors = contactPointsState ? getContactPointErrors(actualContactPoint, contactPointsState) : []; - contactPointErrors.forEach((error) => { - errors.push(error); - }); - - const hasInheritedProperties = inheritedProperties && Object.keys(inheritedProperties).length > 0; - const childPolicies = currentRoute.routes ?? []; - - const inheritedGrouping = hasInheritedProperties && inheritedProperties.group_by; - const noGrouping = isArray(groupBy) && groupBy[0] === '...'; - const customGrouping = !noGrouping && isArray(groupBy) && groupBy.length > 0; - const singleGroup = isDefaultPolicy && isArray(groupBy) && groupBy.length === 0; - + const [showExportDrawer, toggleShowExportDrawer] = useToggle(false); const matchingAlertGroups = matchingInstancesPreview?.groupsMap?.get(currentRoute.id); - // sum all alert instances for all groups we're handling const numberOfAlertInstances = matchingAlertGroups ? sumBy(matchingAlertGroups, (group) => group.alerts.length) : undefined; - const [showExportDrawer, toggleShowExportDrawer] = useToggle(false); - const showExportAction = exportPoliciesAllowed && exportPoliciesSupported && isDefaultPolicy; - const showEditAction = updatePoliciesSupported && updatePoliciesAllowed; - const showDeleteAction = deletePolicySupported && deletePolicyAllowed && !isDefaultPolicy; + // simplified routing permissions + const [isSupportedToSeeAutogeneratedChunk, isAllowedToSeeAutogeneratedChunk] = useAlertmanagerAbility( + AlertmanagerAction.ViewAutogeneratedPolicyTree + ); + // collapsible policies variables + const isThisPolicyCollapsible = useShouldPolicyBeCollapsible(currentRoute); + const [isBranchOpen, toggleBranchOpen] = useToggle(false); + const renderChildPolicies = (isThisPolicyCollapsible && isBranchOpen) || !isThisPolicyCollapsible; + + const groupBy = currentRoute.group_by; + const muteTimings = currentRoute.mute_time_intervals ?? []; + + const timingOptions: TimingOptions = { + group_wait: currentRoute.group_wait, + group_interval: currentRoute.group_interval, + repeat_interval: currentRoute.repeat_interval, + }; + + contactPointErrors.forEach((error) => { + errors.push(error); + }); + + const isAutogeneratedPolicyRoot = isAutoGeneratedRootAndSimplifiedEnabled(currentRoute); // build the menu actions for our policy - const dropdownMenuActions: JSX.Element[] = []; + const dropdownMenuActions: JSX.Element[] = useCreateDropdownMenuActions( + isAutoGenerated, + isDefaultPolicy, + provisioned, + onEditPolicy, + currentRoute, + toggleShowExportDrawer, + onDeletePolicy + ); + + // check if this policy should be visible. If it's autogenerated and the user is not allowed to see autogenerated + // policies then we should not show it. Same if the user is not supported to see autogenerated policies. + const hideCurrentPolicy = + isAutoGenerated && (!isAllowedToSeeAutogeneratedChunk || !isSupportedToSeeAutogeneratedChunk); + + if (hideCurrentPolicy) { + return null; + } + const isImmutablePolicy = isDefaultPolicy || isAutogeneratedPolicyRoot; + // TODO dead branch detection, warnings for all sort of configs that won't work or will never be activated + return ( + <> + +
+ {/* continueMatching and showMatchesAllLabelsWarning are mutually exclusive so the icons can't overlap */} + {continueMatching && } + {showMatchesAllLabelsWarning && } + +
+ + {/* Matchers and actions */} +
+ + {isThisPolicyCollapsible && ( + + )} + {isImmutablePolicy ? ( + isAutogeneratedPolicyRoot ? ( + + ) : ( + + ) + ) : hasMatchers ? ( + + ) : ( + No matchers + )} + + {/* TODO maybe we should move errors to the gutter instead? */} + {errors.length > 0 && } + {provisioned && } + + {!isAutoGenerated && !readOnly && ( + + + + + + )} + {dropdownMenuActions.length > 0 && ( + {dropdownMenuActions}}> +
+ + {/* Metadata row */} + +
+
+
+
+ {renderChildPolicies && ( + <> + {childPolicies.map((child) => { + const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties); + // This child is autogenerated if it's the autogenerated root or if it's a child of an autogenerated policy. + const isThisChildAutoGenerated = isAutoGeneratedRootAndSimplifiedEnabled(child) || isAutoGenerated; + /* pass the "readOnly" prop from the parent, because for any child policy , if its parent it's not editable, + then the child policy should not be editable either */ + const isThisChildReadOnly = readOnly || provisioned || isAutoGenerated; + + return ( + + ); + })} + + )} +
+ {showExportDrawer && } +
+ + ); +}; +/** + * This function returns if the policy should be collapsible or not. + * Add here conditions for policies that should be collapsible. + */ +function useShouldPolicyBeCollapsible(route: RouteWithID): boolean { + const childrenCount = route.routes?.length ?? 0; + const [isSupportedToSeeAutogeneratedChunk, isAllowedToSeeAutogeneratedChunk] = useAlertmanagerAbility( + AlertmanagerAction.ViewAutogeneratedPolicyTree + ); + return ( + childrenCount > 0 && + isSupportedToSeeAutogeneratedChunk && + isAllowedToSeeAutogeneratedChunk && + isAutoGeneratedRootAndSimplifiedEnabled(route) + ); +} + +interface MetadataRowProps { + matchingInstancesPreview: { groupsMap?: Map; enabled: boolean }; + numberOfAlertInstances?: number; + contactPoint?: string; + groupBy?: string[]; + muteTimings?: string[]; + timingOptions?: TimingOptions; + inheritedProperties?: Partial; + alertManagerSourceName: string; + receivers: Receiver[]; + matchingAlertGroups?: AlertmanagerGroup[]; + matchers?: ObjectMatcher[]; + isDefaultPolicy: boolean; + onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void; +} + +function MetadataRow({ + numberOfAlertInstances, + isDefaultPolicy, + timingOptions, + groupBy, + muteTimings = [], + matchingInstancesPreview, + inheritedProperties, + matchingAlertGroups, + onShowAlertInstances, + matchers, + contactPoint, + alertManagerSourceName, + receivers, +}: MetadataRowProps) { + const styles = useStyles2(getStyles); + + const inheritedGrouping = inheritedProperties && inheritedProperties.group_by; + const hasInheritedProperties = inheritedProperties && Object.keys(inheritedProperties).length > 0; + + const noGrouping = isArray(groupBy) && groupBy[0] === '...'; + const customGrouping = !noGrouping && isArray(groupBy) && groupBy.length > 0; + const singleGroup = isDefaultPolicy && isArray(groupBy) && groupBy.length === 0; + + const hasMuteTimings = Boolean(muteTimings.length); + + return ( +
+ + {matchingInstancesPreview.enabled && ( + { + matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers); + }} + data-testid="matching-instances" + > + {numberOfAlertInstances ?? '-'} + {pluralize('instance', numberOfAlertInstances)} + + )} + {contactPoint && ( + + Delivered to + + + )} + {!inheritedGrouping && ( + <> + {customGrouping && ( + + Grouped by + {groupBy.join(', ')} + + )} + {singleGroup && ( + + Single group + + )} + {noGrouping && ( + + Not grouping + + )} + + )} + {hasMuteTimings && ( + + Muted when + + + )} + {timingOptions && ( + // for the default policy we will also merge the default timings, that way a user can observe what the timing options would be + + )} + {hasInheritedProperties && ( + <> + + Inherited + + + + )} + +
+ ); +} + +export const useCreateDropdownMenuActions = ( + isAutoGenerated: boolean, + isDefaultPolicy: boolean, + provisioned: boolean, + onEditPolicy: (route: RouteWithID, isDefault?: boolean, readOnly?: boolean) => void, + currentRoute: RouteWithID, + toggleShowExportDrawer: (nextValue?: any) => void, + onDeletePolicy: (route: RouteWithID) => void +) => { + const [ + [updatePoliciesSupported, updatePoliciesAllowed], + [deletePolicySupported, deletePolicyAllowed], + [exportPoliciesSupported, exportPoliciesAllowed], + ] = useAlertmanagerAbilities([ + AlertmanagerAction.UpdateNotificationPolicyTree, + AlertmanagerAction.DeleteNotificationPolicy, + AlertmanagerAction.ExportNotificationPolicies, + ]); + const dropdownMenuActions = []; + const showExportAction = exportPoliciesAllowed && exportPoliciesSupported && isDefaultPolicy && !isAutoGenerated; + const showEditAction = updatePoliciesSupported && updatePoliciesAllowed; + const showDeleteAction = deletePolicySupported && deletePolicyAllowed && !isDefaultPolicy && !isAutoGenerated; if (showEditAction) { dropdownMenuActions.push( @@ -150,7 +452,7 @@ const Policy: FC = ({ onEditPolicy(currentRoute, isDefaultPolicy)} /> @@ -173,7 +475,7 @@ const Policy: FC = ({ onDeletePolicy(currentRoute)} /> @@ -181,167 +483,31 @@ const Policy: FC = ({ ); } - - // TODO dead branch detection, warnings for all sort of configs that won't work or will never be activated - return ( - -
- {/* continueMatching and showMatchesAllLabelsWarning are mutually exclusive so the icons can't overlap */} - {continueMatching && } - {showMatchesAllLabelsWarning && } -
- - {/* Matchers and actions */} -
- - {isDefaultPolicy ? ( - - ) : hasMatchers ? ( - - ) : ( - No matchers - )} - - {/* TODO maybe we should move errors to the gutter instead? */} - {errors.length > 0 && } - {provisioned && } - {!readOnly && ( - - - - - - - {dropdownMenuActions.length > 0 && ( - {dropdownMenuActions}}> -
- - {/* Metadata row */} -
- - {matchingInstancesPreview.enabled && ( - { - matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers); - }} - data-testid="matching-instances" - > - {numberOfAlertInstances ?? '-'} - {pluralize('instance', numberOfAlertInstances)} - - )} - {contactPoint && ( - - Delivered to - - - )} - {!inheritedGrouping && ( - <> - {customGrouping && ( - - Grouped by - {groupBy.join(', ')} - - )} - {singleGroup && ( - - Single group - - )} - {noGrouping && ( - - Not grouping - - )} - - )} - {hasMuteTimings && ( - - Muted when - - - )} - {timingOptions && ( - // for the default policy we will also merge the default timings, that way a user can observe what the timing options would be - - )} - {hasInheritedProperties && ( - <> - - Inherited - - - - )} - -
-
-
-
-
- {/* pass the "readOnly" prop from the parent, because if you can't edit the parent you can't edit children */} - {childPolicies.map((child) => { - const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties); - - return ( - - ); - })} -
- {showExportDrawer && } -
- ); + return dropdownMenuActions; }; +export const AUTOGENERATED_ROOT_LABEL_NAME = '__grafana_autogenerated__'; + +export function isAutoGeneratedRootAndSimplifiedEnabled(route: RouteWithID) { + const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false; + if (!simplifiedRoutingToggleEnabled) { + return false; + } + if (!route.object_matchers) { + return false; + } + return ( + route.object_matchers.some((objectMatcher) => { + return ( + objectMatcher[0] === AUTOGENERATED_ROOT_LABEL_NAME && + objectMatcher[1] === MatcherOperator.equal && + objectMatcher[2] === 'true' + ); + }) ?? false + ); + // return simplifiedRoutingToggleEnabled && route.receiver === 'contact_point_5'; +} + const ProvisionedTooltip = (children: ReactNode) => ( {children} @@ -388,7 +554,7 @@ const AllMatchesIndicator: FC = () => { ); }; -const DefaultPolicyIndicator: FC = () => { +function DefaultPolicyIndicator() { const styles = useStyles2(getStyles); return ( <> @@ -398,7 +564,11 @@ const DefaultPolicyIndicator: FC = () => { ); -}; +} + +function AutogeneratedRootIndicator() { + return Auto-generated policies; +} const InheritedProperties: FC<{ properties: InhertitableProperties }> = ({ properties }) => ( ({ const { color, borderColor } = getTagColorsFromName(label); return { - wrapper: css` - color: #fff; - background: ${color}; - padding: ${theme.spacing(0.33)} ${theme.spacing(0.66)}; - font-size: ${theme.typography.bodySmall.fontSize}; - - border: solid 1px ${borderColor}; - border-radius: ${theme.shape.radius.default}; - `, + wrapper: css({ + color: '#fff', + background: color, + padding: `${theme.spacing(0.33)} ${theme.spacing(0.66)}`, + fontSize: theme.typography.bodySmall.fontSize, + border: `solid 1px ${borderColor}`, + borderRadius: theme.shape.radius.default, + }), }; }, - childPolicies: css` - margin-left: ${theme.spacing(4)}; - position: relative; - - &:before { - content: ''; - position: absolute; - height: calc(100% - 10px); - - border-left: solid 1px ${theme.colors.border.weak}; - - margin-top: 0; - margin-left: -20px; - } - `, - policyItemWrapper: css` - padding: ${theme.spacing(1.5)}; - `, - metadataRow: css` - background: ${theme.colors.background.secondary}; - - border-bottom-left-radius: ${theme.shape.borderRadius(2)}; - border-bottom-right-radius: ${theme.shape.borderRadius(2)}; - `, - policyWrapper: (hasFocus = false) => css` - flex: 1; - position: relative; - background: ${theme.colors.background.secondary}; - - border-radius: ${theme.shape.radius.default}; - border: solid 1px ${theme.colors.border.weak}; - - ${hasFocus && - css` - border-color: ${theme.colors.primary.border}; - `} - `, - metadata: css` - color: ${theme.colors.text.secondary}; - - font-size: ${theme.typography.bodySmall.fontSize}; - font-weight: ${theme.typography.bodySmall.fontWeight}; - `, - break: css` - width: 100%; - height: 0; - margin-bottom: ${theme.spacing(2)}; - `, - gutterIcon: css` - position: absolute; - - top: 0; - transform: translateY(50%); - left: -${theme.spacing(4)}; - - color: ${theme.colors.text.secondary}; - background: ${theme.colors.background.primary}; - - width: 25px; - height: 25px; - text-align: center; - - border: solid 1px ${theme.colors.border.weak}; - border-radius: ${theme.shape.radius.default}; - - padding: 0; - `, + childPolicies: css({ + marginLeft: theme.spacing(4), + position: 'relative', + '&:before': { + content: '""', + position: 'absolute', + height: 'calc(100% - 10px)', + borderLeft: `solid 1px ${theme.colors.border.weak}`, + marginTop: 0, + marginLeft: '-20px', + }, + }), + policyItemWrapper: css({ + padding: theme.spacing(1.5), + }), + metadataRow: css({ + background: theme.colors.background.secondary, + borderBottomLeftRadius: theme.shape.borderRadius(2), + borderBottomRightRadius: theme.shape.borderRadius(2), + }), + policyWrapper: (hasFocus = false) => + css({ + flex: 1, + position: 'relative', + background: theme.colors.background.secondary, + borderRadius: theme.shape.radius.default, + border: `solid 1px ${theme.colors.border.weak}`, + ...(hasFocus && { borderColor: theme.colors.primary.border }), + }), + metadata: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.bodySmall.fontWeight, + }), + break: css({ + width: '100%', + height: 0, + marginBottom: theme.spacing(2), + }), + gutterIcon: css({ + position: 'absolute', + top: 0, + transform: 'translateY(50%)', + left: `-${theme.spacing(4)}`, + color: theme.colors.text.secondary, + background: theme.colors.background.primary, + width: '25px', + height: '25px', + textAlign: 'center', + border: `solid 1px ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + padding: 0, + }), }); export { Policy }; diff --git a/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap b/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap index 98f40f5cac5..52ae3a31f20 100644 --- a/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap +++ b/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap @@ -78,6 +78,10 @@ exports[`alertmanager abilities should report Create / Update / Delete actions a true, false, ], + "view-autogenerated-policy-tree": [ + false, + false, + ], "view-contact-point": [ true, false, @@ -183,6 +187,10 @@ exports[`alertmanager abilities should report everything except exporting for Mi true, true, ], + "view-autogenerated-policy-tree": [ + false, + false, + ], "view-contact-point": [ true, true, @@ -288,6 +296,10 @@ exports[`alertmanager abilities should report everything is supported for builti true, false, ], + "view-autogenerated-policy-tree": [ + true, + false, + ], "view-contact-point": [ true, true, diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index 0638fc4b2bb..df4cf5e5eb7 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { contextSrv as ctx } from 'app/core/services/context_srv'; +import { contextSrv, contextSrv as ctx } from 'app/core/services/context_srv'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; @@ -45,6 +45,7 @@ export enum AlertmanagerAction { UpdateNotificationPolicyTree = 'update-notification-policy-tree', DeleteNotificationPolicy = 'delete-notification-policy', ExportNotificationPolicies = 'export-notification-policies', + ViewAutogeneratedPolicyTree = 'view-autogenerated-policy-tree', // silences – these cannot be deleted only "expired" (updated) CreateSilence = 'create-silence', @@ -190,6 +191,9 @@ export function useAllAlertmanagerAbilities(): Abilities { const notificationsPermissions = getNotificationsPermissions(selectedAlertmanager!); const instancePermissions = getInstancesPermissions(selectedAlertmanager!); + //we need to know user role to determine if they can view autogenerated policy tree + const isAdmin = contextSrv.hasRole('Admin') || contextSrv.isGrafanaAdmin; + // list out all of the abilities, and if the user has permissions to perform them const abilities: Abilities = { // -- configuration -- @@ -226,6 +230,7 @@ export function useAllAlertmanagerAbilities(): Abilities { isGrafanaFlavoredAlertmanager, notificationsPermissions.provisioning.readSecrets ), + [AlertmanagerAction.ViewAutogeneratedPolicyTree]: [isGrafanaFlavoredAlertmanager, isAdmin], // -- silences -- // for now, all supported Alertmanager flavors have API endpoints for managing silences [AlertmanagerAction.CreateSilence]: toAbility(AlwaysSupported, instancePermissions.create), From 8adfad405521f5817d5b86936252eeb078b404b6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 12:30:10 +0000 Subject: [PATCH 23/29] Update dependency @grafana/faro-web-sdk to v1.3.6 --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index c499b77d715..9ebc5c02a52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3284,7 +3284,7 @@ __metadata: languageName: node linkType: hard -"@grafana/faro-core@npm:^1.3.5": +"@grafana/faro-core@npm:^1.3.5, @grafana/faro-core@npm:^1.3.6": version: 1.3.6 resolution: "@grafana/faro-core@npm:1.3.6" dependencies: @@ -3296,13 +3296,13 @@ __metadata: linkType: hard "@grafana/faro-web-sdk@npm:^1.3.5": - version: 1.3.5 - resolution: "@grafana/faro-web-sdk@npm:1.3.5" + version: 1.3.6 + resolution: "@grafana/faro-web-sdk@npm:1.3.6" dependencies: - "@grafana/faro-core": "npm:^1.3.5" + "@grafana/faro-core": "npm:^1.3.6" ua-parser-js: "npm:^1.0.32" web-vitals: "npm:^3.1.1" - checksum: 003a25a43844b6dff2b99a44a5269951be3b6164c8832a60e80f878d9a463c36ab8a41e83083c83deb1348fead332a38df687ba0ac895dffef40dc6ce3bd967c + checksum: 08a80e5b0b527a4955e803984d53f53fac6dd090b17a219853222090445e15601971f4b469648c59d0075106f6e8f4ddcabae1b0d3010f80a6d900d825656998 languageName: node linkType: hard From 986ddc1ad6bde99f71305dad86602fe5c0284c1b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 13:17:25 +0000 Subject: [PATCH 24/29] Update dependency jest-fail-on-console to v3.1.2 --- package.json | 2 +- yarn.lock | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index cdd176c72b3..6eb35dc2e09 100644 --- a/package.json +++ b/package.json @@ -194,7 +194,7 @@ "jest-canvas-mock": "2.5.2", "jest-date-mock": "1.0.8", "jest-environment-jsdom": "29.7.0", - "jest-fail-on-console": "3.1.1", + "jest-fail-on-console": "3.1.2", "jest-junit": "16.0.0", "jest-matcher-utils": "29.7.0", "lerna": "7.4.1", diff --git a/yarn.lock b/yarn.lock index 9ebc5c02a52..c611c283599 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17213,7 +17213,7 @@ __metadata: jest-canvas-mock: "npm:2.5.2" jest-date-mock: "npm:1.0.8" jest-environment-jsdom: "npm:29.7.0" - jest-fail-on-console: "npm:3.1.1" + jest-fail-on-console: "npm:3.1.2" jest-junit: "npm:16.0.0" jest-matcher-utils: "npm:29.7.0" jquery: "npm:3.7.0" @@ -19424,12 +19424,10 @@ __metadata: languageName: node linkType: hard -"jest-fail-on-console@npm:3.1.1": - version: 3.1.1 - resolution: "jest-fail-on-console@npm:3.1.1" - dependencies: - chalk: "npm:^4.1.0" - checksum: 6d0c79fd5c64a80ce56faf84cbb9b1325782e0bc0a3f1479f2a20d305e61b00bffcb5aba3622515deb75c76a16c8caeac3b33044ec601c55884b81ff13988313 +"jest-fail-on-console@npm:3.1.2": + version: 3.1.2 + resolution: "jest-fail-on-console@npm:3.1.2" + checksum: 6f47a36cd9481121302660dbf29a6ce2443d0c2ad6ad872ee509fc538d674cdaa217f9bd041ce3771c1d8fda081ddcd85221027bbe6c9d2681cdae9d0922d329 languageName: node linkType: hard From 7a3a7221329c5ab714498c61dd803ca5088a1c02 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 13:17:53 +0000 Subject: [PATCH 25/29] Update dependency jquery to v3.7.1 --- package.json | 4 ++-- packages/grafana-data/package.json | 2 +- packages/grafana-ui/package.json | 4 ++-- yarn.lock | 26 +++++++++++++------------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 6eb35dc2e09..505673ecf45 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "@types/history": "4.7.11", "@types/hoist-non-react-statics": "3.3.5", "@types/jest": "29.5.11", - "@types/jquery": "3.5.16", + "@types/jquery": "3.5.29", "@types/js-yaml": "^4.0.5", "@types/jsurl": "^1.2.28", "@types/lodash": "4.14.202", @@ -332,7 +332,7 @@ "i18next-browser-languagedetector": "^7.0.2", "immer": "10.0.3", "immutable": "4.3.4", - "jquery": "3.7.0", + "jquery": "3.7.1", "js-yaml": "^4.1.0", "json-markup": "^1.1.0", "json-source-map": "0.6.1", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 4cf5f390978..2624982e7a0 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -73,7 +73,7 @@ "@types/dompurify": "^2", "@types/history": "4.7.11", "@types/jest": "29.5.11", - "@types/jquery": "3.5.16", + "@types/jquery": "3.5.29", "@types/lodash": "4.14.202", "@types/marked": "5.0.2", "@types/node": "20.8.10", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 4a4019d8f9c..e2ea6ac93d5 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -74,7 +74,7 @@ "i18next-browser-languagedetector": "^7.0.2", "immutable": "4.3.4", "is-hotkey": "0.2.0", - "jquery": "3.7.0", + "jquery": "3.7.1", "lodash": "4.17.21", "micro-memoize": "^4.1.2", "moment": "2.29.4", @@ -140,7 +140,7 @@ "@types/hoist-non-react-statics": "3.3.5", "@types/is-hotkey": "0.1.10", "@types/jest": "29.5.11", - "@types/jquery": "3.5.16", + "@types/jquery": "3.5.29", "@types/lodash": "4.14.202", "@types/mock-raf": "1.0.6", "@types/node": "20.8.10", diff --git a/yarn.lock b/yarn.lock index c611c283599..7363f8a7e38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3089,7 +3089,7 @@ __metadata: "@types/dompurify": "npm:^2" "@types/history": "npm:4.7.11" "@types/jest": "npm:29.5.11" - "@types/jquery": "npm:3.5.16" + "@types/jquery": "npm:3.5.29" "@types/lodash": "npm:4.14.202" "@types/marked": "npm:5.0.2" "@types/node": "npm:20.8.10" @@ -3550,7 +3550,7 @@ __metadata: "@types/hoist-non-react-statics": "npm:3.3.5" "@types/is-hotkey": "npm:0.1.10" "@types/jest": "npm:29.5.11" - "@types/jquery": "npm:3.5.16" + "@types/jquery": "npm:3.5.29" "@types/lodash": "npm:4.14.202" "@types/mock-raf": "npm:1.0.6" "@types/node": "npm:20.8.10" @@ -3588,7 +3588,7 @@ __metadata: i18next-browser-languagedetector: "npm:^7.0.2" immutable: "npm:4.3.4" is-hotkey: "npm:0.2.0" - jquery: "npm:3.7.0" + jquery: "npm:3.7.1" lodash: "npm:4.17.21" micro-memoize: "npm:^4.1.2" mock-raf: "npm:1.0.1" @@ -8686,12 +8686,12 @@ __metadata: languageName: node linkType: hard -"@types/jquery@npm:3.5.16": - version: 3.5.16 - resolution: "@types/jquery@npm:3.5.16" +"@types/jquery@npm:3.5.29": + version: 3.5.29 + resolution: "@types/jquery@npm:3.5.29" dependencies: "@types/sizzle": "npm:*" - checksum: 0f6aaed2616284cfd304083c27fa74fc2fc519199cbfef9c4be78cee5e919929dd5406799a2dd7b9add138699524872f575d9263cc5b7fe82f69a9eba378956b + checksum: 364facf2cb0bc935cb4b7f4af0b12c8e901b02b08a85af213172485166123b10aad8563f8e51e363f65a778c4105a59a64dedd0b0fd73f96557219d46477af56 languageName: node linkType: hard @@ -17091,7 +17091,7 @@ __metadata: "@types/history": "npm:4.7.11" "@types/hoist-non-react-statics": "npm:3.3.5" "@types/jest": "npm:29.5.11" - "@types/jquery": "npm:3.5.16" + "@types/jquery": "npm:3.5.29" "@types/js-yaml": "npm:^4.0.5" "@types/jsurl": "npm:^1.2.28" "@types/lodash": "npm:4.14.202" @@ -17216,7 +17216,7 @@ __metadata: jest-fail-on-console: "npm:3.1.2" jest-junit: "npm:16.0.0" jest-matcher-utils: "npm:29.7.0" - jquery: "npm:3.7.0" + jquery: "npm:3.7.1" js-yaml: "npm:^4.1.0" json-markup: "npm:^1.1.0" json-source-map: "npm:0.6.1" @@ -19802,10 +19802,10 @@ __metadata: languageName: node linkType: hard -"jquery@npm:3.7.0": - version: 3.7.0 - resolution: "jquery@npm:3.7.0" - checksum: ff4c075740048157e0cd131c83b9359b4608b13c4493ab41a5459144a85c06450ae1a254b281a0d56d785d995fd9ad03d2caae17a3b408c53862241fd8ada494 +"jquery@npm:3.7.1": + version: 3.7.1 + resolution: "jquery@npm:3.7.1" + checksum: 17be9929f5fa37697d9848284f0d108c543318ef79ec794e130cd0c49f6c050d60c803a69e8cfa16fa19f5ff7cdb814a6905cceab0831186560c65ed113cd579 languageName: node linkType: hard From 87b613411c1d15704caa14dcc090f0c4429dfe44 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 14:24:50 +0000 Subject: [PATCH 26/29] Update dependency kbar to v0.1.0-beta.45 (#80554) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 505673ecf45..a9db6bab1e4 100644 --- a/package.json +++ b/package.json @@ -337,7 +337,7 @@ "json-markup": "^1.1.0", "json-source-map": "0.6.1", "jsurl": "^0.1.5", - "kbar": "0.1.0-beta.44", + "kbar": "0.1.0-beta.45", "leven": "^4.0.0", "lodash": "4.17.21", "logfmt": "^1.3.2", diff --git a/yarn.lock b/yarn.lock index 7363f8a7e38..3d8c7515f37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17221,7 +17221,7 @@ __metadata: json-markup: "npm:^1.1.0" json-source-map: "npm:0.6.1" jsurl: "npm:^0.1.5" - kbar: "npm:0.1.0-beta.44" + kbar: "npm:0.1.0-beta.45" lerna: "npm:7.4.1" leven: "npm:^4.0.0" lodash: "npm:4.17.21" @@ -20171,9 +20171,9 @@ __metadata: languageName: node linkType: hard -"kbar@npm:0.1.0-beta.44": - version: 0.1.0-beta.44 - resolution: "kbar@npm:0.1.0-beta.44" +"kbar@npm:0.1.0-beta.45": + version: 0.1.0-beta.45 + resolution: "kbar@npm:0.1.0-beta.45" dependencies: "@radix-ui/react-portal": "npm:^1.0.1" fast-equals: "npm:^2.0.3" @@ -20183,7 +20183,7 @@ __metadata: peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 - checksum: 2d22a5d4ac348647b6fe2222f36a8023b8ebcfaccef18b84ac1173c6532a31fab5ce8795ce5a422fca1499265a6d0f0b2c8c233e9008e6782b0805a1707f6ba7 + checksum: 16d161b8563400cc5bff15cf1ba881eb7a00e7bb8b5bd8b67c878fdb60a3db7380ee36f2ac29f15361ece0b214c7d5322d02c954381036d3f72a5227d8d3eb47 languageName: node linkType: hard From b23ecaa3d1d7a6f63eadd70143dd429fbe04f9b3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 14:13:30 +0000 Subject: [PATCH 27/29] Update dependency marked-mangle to v1.1.6 --- package.json | 2 +- packages/grafana-data/package.json | 2 +- yarn.lock | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index a9db6bab1e4..e600f50006a 100644 --- a/package.json +++ b/package.json @@ -345,7 +345,7 @@ "lru-memoize": "^1.1.0", "lucene": "^2.1.1", "marked": "5.1.1", - "marked-mangle": "1.1.0", + "marked-mangle": "1.1.6", "memoize-one": "6.0.0", "ml-regression-polynomial": "^3.0.0", "ml-regression-simple-linear": "^3.0.0", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 2624982e7a0..291f5603f19 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -47,7 +47,7 @@ "history": "4.10.1", "lodash": "4.17.21", "marked": "5.1.1", - "marked-mangle": "1.1.0", + "marked-mangle": "1.1.6", "moment": "2.29.4", "moment-timezone": "0.5.43", "ol": "7.4.0", diff --git a/yarn.lock b/yarn.lock index 3d8c7515f37..af9ca874e97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3108,7 +3108,7 @@ __metadata: history: "npm:4.10.1" lodash: "npm:4.17.21" marked: "npm:5.1.1" - marked-mangle: "npm:1.1.0" + marked-mangle: "npm:1.1.6" moment: "npm:2.29.4" moment-timezone: "npm:0.5.43" ol: "npm:7.4.0" @@ -17230,7 +17230,7 @@ __metadata: lru-memoize: "npm:^1.1.0" lucene: "npm:^2.1.1" marked: "npm:5.1.1" - marked-mangle: "npm:1.1.0" + marked-mangle: "npm:1.1.6" memoize-one: "npm:6.0.0" mini-css-extract-plugin: "npm:2.7.6" ml-regression-polynomial: "npm:^3.0.0" @@ -20996,12 +20996,12 @@ __metadata: languageName: node linkType: hard -"marked-mangle@npm:1.1.0": - version: 1.1.0 - resolution: "marked-mangle@npm:1.1.0" +"marked-mangle@npm:1.1.6": + version: 1.1.6 + resolution: "marked-mangle@npm:1.1.6" peerDependencies: - marked: ^4 || ^5 - checksum: 3897cb6b0ed580e9be029bf78f8b3b22534ca3e0c061fd9e45d0f256263c01f9667122d68235c767583c7109347bd1cc823dca5f428b95460123ba4b212cdb11 + marked: ">=4 <12" + checksum: 70e7fa1e318cd69080e2f204e86263c419dd98d832571cfea03b09970206e5aea0aabb2921b43ebce6deec84b1c93c3365a03a1173d373a581423577c7f84325 languageName: node linkType: hard From d5db67a0731a44706fa8fe263e62f51d26b7e2e2 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 15 Jan 2024 14:29:39 +0000 Subject: [PATCH 28/29] Chore: type improvements (#80464) * type improvements * some more fixes * add TODOs to remove type assertions --- .betterer.results | 65 +++------- .../grafana-data/src/types/fieldOverrides.ts | 7 +- packages/grafana-data/src/utils/location.ts | 2 +- .../src/utils/DataSourceWithBackend.ts | 6 +- public/app/core/specs/ticks.test.ts | 21 --- public/app/core/utils/explore.ts | 11 +- public/app/core/utils/fetch.ts | 16 ++- public/app/core/utils/ticks.ts | 121 ------------------ .../DashboardModelCompatibilityWrapper.ts | 4 +- .../plugins/sql/datasource/SqlDatasource.ts | 6 +- .../features/query/state/PanelQueryRunner.ts | 11 +- public/app/features/sandbox/TestStuffPage.tsx | 14 +- public/app/features/storage/storage.ts | 4 +- .../transformers/calculateHeatmap/heatmap.ts | 3 +- .../WindowOptionsEditor.tsx | 11 +- .../app/features/variables/adhoc/actions.ts | 11 +- .../components/SimulationQueryEditor.tsx | 2 +- 17 files changed, 78 insertions(+), 237 deletions(-) diff --git a/.betterer.results b/.betterer.results index 16780915d45..2730e52414c 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1,5 +1,5 @@ // BETTERER RESULTS V2. -// +// // If this file contains merge conflicts, use `betterer merge` to automatically resolve them: // https://phenomnomnominal.github.io/betterer/docs/results-file/#merge // @@ -293,17 +293,14 @@ exports[`better eslint`] = { ], "packages/grafana-data/src/types/fieldOverrides.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"], [0, 0, 0, "Unexpected any. Specify a different type.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"], - [0, 0, 0, "Unexpected any. Specify a different type.", "11"] + [0, 0, 0, "Unexpected any. Specify a different type.", "8"] ], "packages/grafana-data/src/types/flot.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -435,8 +432,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-data/src/utils/location.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "packages/grafana-data/src/utils/url.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -578,10 +574,7 @@ exports[`better eslint`] = { ], "packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "packages/grafana-runtime/src/utils/queryResponse.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -1184,21 +1177,13 @@ exports[`better eslint`] = { "public/app/core/utils/deferred.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/core/utils/explore.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/core/utils/fetch.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Do not use any type assertions.", "3"], [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Do not use any type assertions.", "8"], - [0, 0, 0, "Unexpected any. Specify a different type.", "9"], - [0, 0, 0, "Unexpected any. Specify a different type.", "10"] + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] ], "public/app/core/utils/flatten.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -1214,12 +1199,8 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/core/utils/ticks.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"] + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/core/utils/tracing.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -2461,9 +2442,7 @@ exports[`better eslint`] = { ], "public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/features/dashboard-scene/utils/test-utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -4184,8 +4163,7 @@ exports[`better eslint`] = { ], "public/app/features/query/state/PanelQueryRunner.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/features/query/state/runRequest.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -4204,9 +4182,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "10"], [0, 0, 0, "Unexpected any. Specify a different type.", "11"] ], - "public/app/features/sandbox/TestStuffPage.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/search/page/components/ActionRow.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] @@ -4308,9 +4283,7 @@ exports[`better eslint`] = { ], "public/app/features/storage/storage.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/features/teams/TeamGroupSync.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -4385,8 +4358,7 @@ exports[`better eslint`] = { ], "public/app/features/transformers/calculateHeatmap/heatmap.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] @@ -4398,9 +4370,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/transformers/editors/CalculateFieldTransformerEditor/WindowOptionsEditor.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -4495,9 +4465,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "public/app/features/variables/adhoc/actions.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/variables/adhoc/picker/AdHocFilterRenderer.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/packages/grafana-data/src/types/fieldOverrides.ts b/packages/grafana-data/src/types/fieldOverrides.ts index dad3547523c..1ca74a3be06 100644 --- a/packages/grafana-data/src/types/fieldOverrides.ts +++ b/packages/grafana-data/src/types/fieldOverrides.ts @@ -46,8 +46,7 @@ export interface SystemConfigOverrideRule extends ConfigOverrideRule { */ export function isSystemOverrideWithRef(ref: string) { return (override: ConfigOverrideRule): override is T => { - const overrideAs = override as T; - return overrideAs.__systemRef === ref; + return '__systemRef' in override && override.__systemRef === ref; }; } @@ -58,7 +57,7 @@ export function isSystemOverrideWithRef(ref: * @internal */ export const isSystemOverride = (override: ConfigOverrideRule): override is SystemConfigOverrideRule => { - return typeof (override as SystemConfigOverrideRule)?.__systemRef === 'string'; + return '__systemRef' in override && typeof override.__systemRef === 'string'; }; export interface FieldConfigSource { @@ -69,7 +68,7 @@ export interface FieldConfigSource { overrides: ConfigOverrideRule[]; } -export interface FieldOverrideContext extends StandardEditorContext { +export interface FieldOverrideContext extends StandardEditorContext { field?: Field; dataFrameIndex?: number; // The index for the selected field frame } diff --git a/packages/grafana-data/src/utils/location.ts b/packages/grafana-data/src/utils/location.ts index 44d42f247a5..5c0a2167025 100644 --- a/packages/grafana-data/src/utils/location.ts +++ b/packages/grafana-data/src/utils/location.ts @@ -69,7 +69,7 @@ const assureBaseUrl = (url: string): string => { * @param searchParamsToUpdate * @returns */ -const getUrlForPartial = (location: Location, searchParamsToUpdate: UrlQueryMap) => { +const getUrlForPartial = (location: Location, searchParamsToUpdate: UrlQueryMap) => { const searchParams = urlUtil.parseKeyValue( location.search.startsWith('?') ? location.search.substring(1) : location.search ); diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts index 7085f0dc47a..4f236a28744 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -268,7 +268,7 @@ class DataSourceWithBackend< * Apply template variables for explore */ interpolateVariablesInQueries(queries: TQuery[], scopedVars: ScopedVars, filters?: AdHocVariableFilter[]): TQuery[] { - return queries.map((q) => this.applyTemplateVariables(q, scopedVars, filters) as TQuery); + return queries.map((q) => this.applyTemplateVariables(q, scopedVars, filters)); } /** @@ -290,7 +290,7 @@ class DataSourceWithBackend< * * @virtual */ - applyTemplateVariables(query: TQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[]): Record { + applyTemplateVariables(query: TQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[]) { return query; } @@ -323,7 +323,7 @@ class DataSourceWithBackend< /** * Send a POST request to the datasource resource path */ - async postResource( + async postResource( path: string, data?: BackendSrvRequest['data'], options?: Partial diff --git a/public/app/core/specs/ticks.test.ts b/public/app/core/specs/ticks.test.ts index 81ea8601e4f..727be9f7a8f 100644 --- a/public/app/core/specs/ticks.test.ts +++ b/public/app/core/specs/ticks.test.ts @@ -1,27 +1,6 @@ import * as ticks from '../utils/ticks'; describe('ticks', () => { - describe('getFlotTickDecimals()', () => { - const axis = { - min: null, - max: null, - }; - - it('should calculate decimals precision based on graph height', () => { - let dec = ticks.getFlotTickDecimals(0, 10, axis, 200); - expect(dec.tickDecimals).toBe(1); - expect(dec.scaledDecimals).toBe(1); - - dec = ticks.getFlotTickDecimals(0, 100, axis, 200); - expect(dec.tickDecimals).toBe(0); - expect(dec.scaledDecimals).toBe(-1); - - dec = ticks.getFlotTickDecimals(0, 1, axis, 200); - expect(dec.tickDecimals).toBe(2); - expect(dec.scaledDecimals).toBe(3); - }); - }); - describe('getStringPrecision()', () => { it('"3.12" should return 2', () => { expect(ticks.getStringPrecision('3.12')).toBe(2); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 912189120e7..49fff84b18b 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -263,12 +263,11 @@ const validKeys = ['refId', 'key', 'context', 'datasource']; export function hasNonEmptyQuery(queries: TQuery[]): boolean { return ( queries && - queries.some((query: any) => { - const keys = Object.keys(query) - .filter((key) => validKeys.indexOf(key) === -1) - .map((k) => query[k]) - .filter((v) => v); - return keys.length > 0; + queries.some((query) => { + const entries = Object.entries(query) + .filter(([key, _]) => validKeys.indexOf(key) === -1) + .filter(([_, value]) => value); + return entries.length > 0; }) ); } diff --git a/public/app/core/utils/fetch.ts b/public/app/core/utils/fetch.ts index f011ae348b9..49253f74a31 100644 --- a/public/app/core/utils/fetch.ts +++ b/public/app/core/utils/fetch.ts @@ -103,23 +103,29 @@ export async function parseResponseBody( if (responseType) { switch (responseType) { case 'arraybuffer': - return response.arrayBuffer() as any; + // this specifically returns a Promise + // TODO refactor this function to remove the type assertions + return response.arrayBuffer() as Promise; case 'blob': - return response.blob() as any; + // this specifically returns a Promise + // TODO refactor this function to remove the type assertions + return response.blob() as Promise; case 'json': // An empty string is not a valid JSON. // Sometimes (unfortunately) our APIs declare their Content-Type as JSON, however they return an empty body. if (response.headers.get('Content-Length') === '0') { console.warn(`${response.url} returned an invalid JSON`); - return {} as unknown as T; + return {} as T; } return await response.json(); case 'text': - return response.text() as any; + // this specifically returns a Promise + // TODO refactor this function to remove the type assertions + return response.text() as Promise; } } @@ -127,7 +133,7 @@ export async function parseResponseBody( try { return JSON.parse(textData); // majority of the requests this will be something that can be parsed } catch {} - return textData as any; + return textData as T; } function serializeParams(data: Record): string { diff --git a/public/app/core/utils/ticks.ts b/public/app/core/utils/ticks.ts index 5f2239a4d9d..5b65bb22a0f 100644 --- a/public/app/core/utils/ticks.ts +++ b/public/app/core/utils/ticks.ts @@ -30,127 +30,6 @@ export function getScaledDecimals(decimals: number, tickSize: number) { return decimals - Math.floor(Math.log(tickSize) / Math.LN10); } -/** - * Calculate tick size based on min and max values, number of ticks and precision. - * Implementation from Flot. - * @param min Axis minimum - * @param max Axis maximum - * @param noTicks Number of ticks - * @param tickDecimals Tick decimal precision - */ -export function getFlotTickSize(min: number, max: number, noTicks: number, tickDecimals: number) { - const delta = (max - min) / noTicks; - let dec = -Math.floor(Math.log(delta) / Math.LN10); - const maxDec = tickDecimals; - - const magn = Math.pow(10, -dec); - const norm = delta / magn; // norm is between 1.0 and 10.0 - let size; - - if (norm < 1.5) { - size = 1; - } else if (norm < 3) { - size = 2; - // special case for 2.5, requires an extra decimal - if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) { - size = 2.5; - ++dec; - } - } else if (norm < 7.5) { - size = 5; - } else { - size = 10; - } - - size *= magn; - - return size; -} - -/** - * Calculate axis range (min and max). - * Implementation from Flot. - */ -export function getFlotRange(panelMin: any, panelMax: any, datamin: number, datamax: number) { - const autoscaleMargin = 0.02; - - let min = +(panelMin != null ? panelMin : datamin); - let max = +(panelMax != null ? panelMax : datamax); - const delta = max - min; - - if (delta === 0.0) { - // Grafana fix: wide Y min and max using increased wideFactor - // when all series values are the same - const wideFactor = 0.25; - const widen = Math.abs(max === 0 ? 1 : max * wideFactor); - - if (panelMin === null) { - min -= widen; - } - // always widen max if we couldn't widen min to ensure we - // don't fall into min == max which doesn't work - if (panelMax == null || panelMin != null) { - max += widen; - } - } else { - // consider autoscaling - const margin = autoscaleMargin; - if (margin != null) { - if (panelMin == null) { - min -= delta * margin; - // make sure we don't go below zero if all values - // are positive - if (min < 0 && datamin != null && datamin >= 0) { - min = 0; - } - } - if (panelMax == null) { - max += delta * margin; - if (max > 0 && datamax != null && datamax <= 0) { - max = 0; - } - } - } - } - return { min, max }; -} - -/** - * Calculate tick decimals. - * Implementation from Flot. - */ -export function getFlotTickDecimals(datamin: number, datamax: number, axis: { min: any; max: any }, height: number) { - const { min, max } = getFlotRange(axis.min, axis.max, datamin, datamax); - const noTicks = 0.3 * Math.sqrt(height); - const delta = (max - min) / noTicks; - const dec = -Math.floor(Math.log(delta) / Math.LN10); - - const magn = Math.pow(10, -dec); - // norm is between 1.0 and 10.0 - const norm = delta / magn; - let size; - - if (norm < 1.5) { - size = 1; - } else if (norm < 3) { - size = 2; - // special case for 2.5, requires an extra decimal - if (norm > 2.25) { - size = 2.5; - } - } else if (norm < 7.5) { - size = 5; - } else { - size = 10; - } - size *= magn; - - const tickDecimals = Math.max(0, -Math.floor(Math.log(delta) / Math.LN10) + 1); - // grafana addition - const scaledDecimals = tickDecimals - Math.floor(Math.log(size) / Math.LN10); - return { tickDecimals, scaledDecimals }; -} - /** * Format timestamp similar to Grafana graph panel. * @param ticks Number of ticks diff --git a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts index 5a5d96d57ef..e8dde484099 100644 --- a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts +++ b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts @@ -272,12 +272,12 @@ class PanelCompatibilityWrapper { uid: SHARED_DASHBOARD_QUERY, type: 'datasource', }, - ...(this._vizPanel.state.$data.state.$data as ShareQueryDataProvider).state.query, + ...this._vizPanel.state.$data.state.$data.state.query, }, ]; } if (this._vizPanel.state.$data.state.$data instanceof SceneQueryRunner) { - return (this._vizPanel.state.$data.state.$data as SceneQueryRunner).state.queries; + return this._vizPanel.state.$data.state.$data.state.queries; } } diff --git a/public/app/features/plugins/sql/datasource/SqlDatasource.ts b/public/app/features/plugins/sql/datasource/SqlDatasource.ts index 0d4540e91ce..0788326341f 100644 --- a/public/app/features/plugins/sql/datasource/SqlDatasource.ts +++ b/public/app/features/plugins/sql/datasource/SqlDatasource.ts @@ -9,7 +9,6 @@ import { DataQueryRequest, DataQueryResponse, DataSourceInstanceSettings, - DataSourceRef, MetricFindValue, ScopedVars, CoreApp, @@ -117,10 +116,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend { + applyTemplateVariables(target: SQLQuery, scopedVars: ScopedVars) { return { refId: target.refId, datasource: this.getRef(), diff --git a/public/app/features/query/state/PanelQueryRunner.ts b/public/app/features/query/state/PanelQueryRunner.ts index a2911d12f16..ed1f6fcb1e3 100644 --- a/public/app/features/query/state/PanelQueryRunner.ts +++ b/public/app/features/query/state/PanelQueryRunner.ts @@ -139,9 +139,14 @@ export class PanelQueryRunner { if (withFieldConfig && data.series?.length) { if (lastConfigRev === this.dataConfigSource.configRev) { - const streamingDataFrame = data.series.find((data) => isStreamingDataFrame(data)) as - | StreamingDataFrame - | undefined; + let streamingDataFrame: StreamingDataFrame | undefined; + + for (const frame of data.series) { + if (isStreamingDataFrame(frame)) { + streamingDataFrame = frame; + break; + } + } if ( streamingDataFrame && diff --git a/public/app/features/sandbox/TestStuffPage.tsx b/public/app/features/sandbox/TestStuffPage.tsx index 4cdb3a5b7ec..c1e20ed8c87 100644 --- a/public/app/features/sandbox/TestStuffPage.tsx +++ b/public/app/features/sandbox/TestStuffPage.tsx @@ -2,9 +2,15 @@ import React, { useMemo, useState } from 'react'; import { useObservable } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { ApplyFieldOverrideOptions, dateMath, FieldColorModeId, NavModelItem, PanelData } from '@grafana/data'; +import { + ApplyFieldOverrideOptions, + DataConfigSource, + dateMath, + FieldColorModeId, + NavModelItem, + PanelData, +} from '@grafana/data'; import { getPluginExtensions, isPluginExtensionLink } from '@grafana/runtime'; -import { DataTransformerConfig } from '@grafana/schema'; import { Button, HorizontalGroup, LinkButton, Table } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { config } from 'app/core/config'; @@ -129,8 +135,8 @@ export function getDefaultState(): State { theme: config.theme2, }; - const dataConfig = { - getTransformations: () => [] as DataTransformerConfig[], + const dataConfig: DataConfigSource = { + getTransformations: () => [], getFieldOverrideOptions: () => options, getDataSupport: () => ({ annotations: false, alertStates: false }), }; diff --git a/public/app/features/storage/storage.ts b/public/app/features/storage/storage.ts index 41b0527d71f..38c05e9ec38 100644 --- a/public/app/features/storage/storage.ts +++ b/public/app/features/storage/storage.ts @@ -102,9 +102,9 @@ class SimpleStorage implements GrafanaStorage { body: formData, }); - let body: UploadResponse = await res.json(); + let body = await res.json(); if (!body) { - body = {} as any; + body = {}; } body.status = res.status; body.statusText = res.statusText; diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts index a5c770fe9e8..3a57e5845b7 100644 --- a/public/app/features/transformers/calculateHeatmap/heatmap.ts +++ b/public/app/features/transformers/calculateHeatmap/heatmap.ts @@ -327,7 +327,8 @@ export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCa : undefined, yMode: yBucketsCfg.mode, ySize: yBucketsCfg.value ? +yBucketsCfg.value : undefined, - yLog: scaleDistribution?.type === ScaleDistribution.Log ? (scaleDistribution?.log as any) : undefined, + yLog: + scaleDistribution?.type === ScaleDistribution.Log ? (scaleDistribution?.log as 2 | 10 | undefined) : undefined, }); const frame = { diff --git a/public/app/features/transformers/editors/CalculateFieldTransformerEditor/WindowOptionsEditor.tsx b/public/app/features/transformers/editors/CalculateFieldTransformerEditor/WindowOptionsEditor.tsx index ff5090d3e4b..05b10b6e36c 100644 --- a/public/app/features/transformers/editors/CalculateFieldTransformerEditor/WindowOptionsEditor.tsx +++ b/public/app/features/transformers/editors/CalculateFieldTransformerEditor/WindowOptionsEditor.tsx @@ -52,16 +52,15 @@ export const WindowOptionsEditor = (props: { }); }; - const onWindowSizeModeChange = (val: string) => { - const mode = val as WindowSizeMode; + const onWindowSizeModeChange = (val: WindowSizeMode) => { updateWindowOptions({ ...window!, windowSize: window?.windowSize - ? mode === WindowSizeMode.Percentage + ? val === WindowSizeMode.Percentage ? window!.windowSize! / 100 : window!.windowSize! * 100 : undefined, - windowSizeMode: mode, + windowSizeMode: val, }); }; @@ -71,10 +70,10 @@ export const WindowOptionsEditor = (props: { updateWindowOptions({ ...window, reducer }); }; - const onTypeChange = (val: string) => { + const onTypeChange = (val: WindowAlignment) => { updateWindowOptions({ ...window!, - windowAlignment: val as WindowAlignment, + windowAlignment: val, }); }; diff --git a/public/app/features/variables/adhoc/actions.ts b/public/app/features/variables/adhoc/actions.ts index d0dfd6b631d..91d1ed8ef9c 100644 --- a/public/app/features/variables/adhoc/actions.ts +++ b/public/app/features/variables/adhoc/actions.ts @@ -149,7 +149,12 @@ const createAdHocVariable = (options: AdHocTableOptions): ThunkResult => { const getVariableByOptions = (options: AdHocTableOptions, state: StoreState): AdHocVariableModel | undefined => { const key = getLastKey(state); const templatingState = getVariablesState(key, state); - return Object.values(templatingState.variables).find( - (v) => isAdHoc(v) && v.datasource?.uid === options.datasource.uid - ) as AdHocVariableModel; + let result: AdHocVariableModel | undefined; + for (const v of Object.values(templatingState.variables)) { + if (isAdHoc(v) && v.datasource?.uid === options.datasource.uid) { + result = v; + break; + } + } + return result; }; diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx b/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx index 225f3fba094..58af6c6b2a4 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx @@ -90,7 +90,7 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => { if (simKey.uid) { path += '/' + simKey.uid; } - ds.postResource('sim/' + path, config).then((res) => { + ds.postResource('sim/' + path, config).then((res) => { setCfgValue(res.config); }); }; From 1cf53a34d113d55146d0d25eacbaed4002422cf6 Mon Sep 17 00:00:00 2001 From: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Mon, 15 Jan 2024 16:57:45 +0200 Subject: [PATCH 29/29] Scenes: Render versions in dashboard settings (#80229) * Add versions tab in dashboard settings * Fetch and render dashboard versions * PR discussion changes * remove unnecessary async in test * PR discussion mods * linter fix --- .betterer.results | 4 + .../settings/VersionsEditView.test.tsx | 112 +++++++ .../settings/VersionsEditView.tsx | 115 ++++++- .../settings/version-history/DiffGroup.tsx | 57 ++++ .../settings/version-history/DiffTitle.tsx | 61 ++++ .../settings/version-history/DiffValues.tsx | 37 +++ .../settings/version-history/DiffViewer.tsx | 73 +++++ .../version-history/HistorySrv.test.ts | 75 +++++ .../settings/version-history/HistorySrv.ts | 50 +++ .../version-history/RevertDashboardModal.tsx | 34 ++ .../version-history/VersionHistoryButtons.tsx | 31 ++ .../VersionHistoryComparison.tsx | 82 +++++ .../version-history/VersionHistoryHeader.tsx | 41 +++ .../version-history/VersionHistoryTable.tsx | 73 +++++ .../__mocks__/dashboardHistoryMocks.ts | 176 +++++++++++ .../settings/version-history/index.ts | 5 + .../version-history/useDashboardRestore.tsx | 39 +++ .../settings/version-history/utils.test.ts | 295 ++++++++++++++++++ .../settings/version-history/utils.ts | 104 ++++++ .../DashboardSettings/VersionsSettings.tsx | 4 +- .../VersionHistory/HistorySrv.test.ts | 9 +- .../components/VersionHistory/HistorySrv.ts | 9 +- 22 files changed, 1470 insertions(+), 16 deletions(-) create mode 100644 public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx create mode 100644 public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx create mode 100644 public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx create mode 100644 public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx create mode 100644 public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts create mode 100644 public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts create mode 100644 public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx create mode 100644 public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx create mode 100644 public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx create mode 100644 public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx create mode 100644 public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx create mode 100644 public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts create mode 100644 public/app/features/dashboard-scene/settings/version-history/index.ts create mode 100644 public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx create mode 100644 public/app/features/dashboard-scene/settings/version-history/utils.test.ts create mode 100644 public/app/features/dashboard-scene/settings/version-history/utils.ts diff --git a/.betterer.results b/.betterer.results index 2730e52414c..8c7ff46d795 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2440,6 +2440,10 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/settings/variables/utils.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], + "public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], "public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx new file mode 100644 index 00000000000..7b927360567 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx @@ -0,0 +1,112 @@ +import { SceneGridItem, SceneGridLayout, SceneTimeRange } from '@grafana/scenes'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { activateFullSceneTree } from '../utils/test-utils'; + +import { VERSIONS_FETCH_LIMIT, VersionsEditView } from './VersionsEditView'; +import { historySrv } from './version-history'; + +jest.mock('./version-history/HistorySrv'); + +describe('VersionsEditView', () => { + describe('Dashboard Versions state', () => { + let dashboard: DashboardScene; + let versionsView: VersionsEditView; + + beforeEach(async () => { + jest.mocked(historySrv.getHistoryList).mockResolvedValue(getVersions()); + + const result = await buildTestScene(); + dashboard = result.dashboard; + versionsView = result.versionsView; + }); + + it('should return the correct urlKey', () => { + expect(versionsView.getUrlKey()).toBe('versions'); + }); + + it('should return the dashboard', () => { + expect(versionsView.getDashboard()).toBe(dashboard); + }); + + it('should return the decorated list of versions', () => { + const versions = versionsView.versions; + + expect(versions).toHaveLength(2); + expect(versions[0].createdDateString).toBe('2017-02-22 20:43:01'); + expect(versions[0].ageString).toBe('7 years ago'); + expect(versions[1].createdDateString).toBe('2017-02-22 20:43:01'); + expect(versions[1].ageString).toBe('7 years ago'); + }); + + it('should bump the start threshold when fetching more versions', async () => { + expect(versionsView.start).toBe(VERSIONS_FETCH_LIMIT); + + versionsView.fetchVersions(true); + await new Promise(process.nextTick); + + expect(versionsView.start).toBe(VERSIONS_FETCH_LIMIT * 2); + }); + }); +}); + +function getVersions() { + return [ + { + id: 4, + dashboardId: 1, + dashboardUID: '_U4zObQMz', + parentVersion: 3, + restoredFrom: 0, + version: 4, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 3, + dashboardId: 1, + dashboardUID: '_U4zObQMz', + parentVersion: 1, + restoredFrom: 1, + version: 3, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + }, + ]; +} + +async function buildTestScene() { + const versionsView = new VersionsEditView({ versions: [] }); + const dashboard = new DashboardScene({ + $timeRange: new SceneTimeRange({}), + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: undefined, + }), + ], + }), + editview: versionsView, + }); + + activateFullSceneTree(dashboard); + + await new Promise((r) => setTimeout(r, 1)); + + dashboard.onEnterEditMode(); + versionsView.activate(); + + return { dashboard, versionsView }; +} diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx index 1805a00096a..e5f69fee760 100644 --- a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx @@ -1,36 +1,139 @@ import React from 'react'; -import { PageLayoutType } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; +import { PageLayoutType, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes'; +import { HorizontalGroup, Spinner } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { DashboardScene } from '../scene/DashboardScene'; import { getDashboardSceneFor } from '../utils/utils'; import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils'; +import { RevisionsModel, VersionHistoryTable, historySrv } from './version-history'; -export interface VersionsEditViewState extends DashboardEditViewState {} +export const VERSIONS_FETCH_LIMIT = 10; + +export type DecoratedRevisionModel = RevisionsModel & { + createdDateString: string; + ageString: string; +}; + +export interface VersionsEditViewState extends DashboardEditViewState { + versions?: DecoratedRevisionModel[]; + isLoading?: boolean; + isAppending?: boolean; +} export class VersionsEditView extends SceneObjectBase implements DashboardEditView { public static Component = VersionsEditorSettingsListView; + private _limit: number = VERSIONS_FETCH_LIMIT; + private _start = 0; + + constructor(state: VersionsEditViewState) { + super({ + ...state, + versions: [], + isLoading: true, + isAppending: true, + }); + + this.addActivationHandler(() => { + this.fetchVersions(); + }); + } + + private get _dashboard(): DashboardScene { + return getDashboardSceneFor(this); + } + + public get versions(): DecoratedRevisionModel[] { + return this.state.versions ?? []; + } + + public get limit(): number { + return this._limit; + } + + public get start(): number { + return this._start; + } public getUrlKey(): string { return 'versions'; } public getDashboard(): DashboardScene { - return getDashboardSceneFor(this); + return this._dashboard; + } + + public getTimeRange() { + return sceneGraph.getTimeRange(this._dashboard); + } + + public fetchVersions(append = false): void { + const uid = this._dashboard.state.uid; + + if (!uid) { + return; + } + + this.setState({ isAppending: append }); + + historySrv + .getHistoryList(uid, { limit: this._limit, start: this._start }) + .then((result) => { + this.setState({ + isLoading: false, + versions: [...(this.state.versions ?? []), ...this.decorateVersions(result)], + }); + this._start += this._limit; + }) + .catch((err) => console.log(err)) + .finally(() => this.setState({ isAppending: false })); + } + + private decorateVersions(versions: RevisionsModel[]): DecoratedRevisionModel[] { + const timeZone = this.getTimeRange().getTimeZone(); + + return versions.map((version) => { + return { + ...version, + createdDateString: dateTimeFormat(version.created, { timeZone: timeZone }), + ageString: dateTimeFormatTimeAgo(version.created, { timeZone: timeZone }), + checked: false, + }; + }); } } function VersionsEditorSettingsListView({ model }: SceneComponentProps) { const dashboard = model.getDashboard(); - + const { isLoading, isAppending } = model.useState(); const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); + const canCompare = model.versions.filter((version) => version.checked).length === 2; + return ( -
TODO
+ {isLoading ? ( + + ) : ( + { + console.log('todo'); + }} + canCompare={canCompare} + /> + )} + {isAppending && }
); } + +export const VersionsHistorySpinner = ({ msg }: { msg: string }) => ( + + + {msg} + +); diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx new file mode 100644 index 00000000000..d5d67c34d83 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx @@ -0,0 +1,57 @@ +import { css } from '@emotion/css'; +import { last } from 'lodash'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { DiffTitle } from './DiffTitle'; +import { DiffValues } from './DiffValues'; +import { Diff, getDiffText } from './utils'; + +type DiffGroupProps = { + diffs: Diff[]; + title: string; +}; + +export const DiffGroup = ({ diffs, title }: DiffGroupProps) => { + const styles = useStyles2(getStyles); + + if (diffs.length === 1) { + return ( +
+ +
+ ); + } + + return ( +
+ +
    + {diffs.map((diff: Diff, idx: number) => { + return ( +
  • + {getDiffText(diff)} +
  • + ); + })} +
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + 'background-color': theme.colors.background.secondary, + 'font-size': theme.typography.h6.fontSize, + 'margin-bottom': theme.spacing(2), + padding: theme.spacing(2), + }), + list: css({ + 'margin-left': theme.spacing(4), + }), + listItem: css({ + 'margin-bottom': theme.spacing(1), + }), +}); diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx new file mode 100644 index 00000000000..7622da29b7f --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx @@ -0,0 +1,61 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, Icon } from '@grafana/ui'; + +import { DiffValues } from './DiffValues'; +import { Diff, getDiffText } from './utils'; + +type DiffTitleProps = { + diff?: Diff; + title: string; +}; + +const replaceDiff: Diff = { op: 'replace', originalValue: undefined, path: [''], value: undefined, startLineNumber: 0 }; + +export const DiffTitle = ({ diff, title }: DiffTitleProps) => { + const styles = useStyles2(getDiffTitleStyles); + + return diff ? ( + <> + {title}{' '} + {getDiffText(diff, diff.path.length > 1)} + + ) : ( +
+ {title}{' '} + {getDiffText(replaceDiff, false)} +
+ ); +}; + +const getDiffTitleStyles = (theme: GrafanaTheme2) => ({ + embolden: css({ + 'font-weight': `${theme.typography.fontWeightBold}`, + }), + add: css({ + color: theme.colors.success.main, + }), + replace: css({ + color: theme.colors.success.main, + }), + move: css({ + color: theme.colors.success.main, + }), + copy: css({ + color: theme.colors.success.main, + }), + _get: css({ + color: theme.colors.success.main, + }), + test: css({ + color: theme.colors.success.main, + }), + remove: css({ + color: theme.colors.success.main, + }), + withoutDiff: css({ + 'margin-bottom': theme.spacing(2), + }), +}); diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx new file mode 100644 index 00000000000..181b9c07541 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx @@ -0,0 +1,37 @@ +import { css } from '@emotion/css'; +import { isArray, isObject, isUndefined } from 'lodash'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, Icon } from '@grafana/ui'; + +import { Diff } from './utils'; + +type DiffProps = { + diff: Diff; +}; + +export const DiffValues = ({ diff }: DiffProps) => { + const styles = useStyles2(getStyles); + const hasLeftValue = + !isUndefined(diff.originalValue) && !isArray(diff.originalValue) && !isObject(diff.originalValue); + const hasRightValue = !isUndefined(diff.value) && !isArray(diff.value) && !isObject(diff.value); + + return ( + <> + {hasLeftValue && {String(diff.originalValue)}} + {hasLeftValue && hasRightValue ? : null} + {hasRightValue && {String(diff.value)}} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => + css({ + 'background-color': theme.colors.action.hover, + 'border-radius': theme.shape.radius.default, + color: theme.colors.text.primary, + 'font-size': theme.typography.body.fontSize, + margin: `0 ${theme.spacing(0.5)}`, + padding: theme.spacing(0.5, 1), + }); diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx new file mode 100644 index 00000000000..89060012b00 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx @@ -0,0 +1,73 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import ReactDiffViewer, { ReactDiffViewerProps, DiffMethod } from 'react-diff-viewer'; +import tinycolor from 'tinycolor2'; + +import { useTheme2 } from '@grafana/ui'; + +export const DiffViewer = ({ oldValue, newValue }: ReactDiffViewerProps) => { + const theme = useTheme2(); + + const styles = { + variables: { + // the light theme supplied by ReactDiffViewer is very similar to Grafana + // the dark theme needs some tweaks. + dark: { + diffViewerBackground: theme.colors.background.canvas, + diffViewerColor: theme.colors.text.primary, + addedBackground: tinycolor(theme.v1.palette.greenShade).setAlpha(0.3).toString(), + addedColor: 'white', + removedBackground: tinycolor(theme.v1.palette.redShade).setAlpha(0.3).toString(), + removedColor: 'white', + wordAddedBackground: tinycolor(theme.v1.palette.greenBase).setAlpha(0.4).toString(), + wordRemovedBackground: tinycolor(theme.v1.palette.redBase).setAlpha(0.4).toString(), + addedGutterBackground: tinycolor(theme.v1.palette.greenShade).setAlpha(0.2).toString(), + removedGutterBackground: tinycolor(theme.v1.palette.redShade).setAlpha(0.2).toString(), + gutterBackground: theme.colors.background.primary, + gutterBackgroundDark: theme.colors.background.primary, + highlightBackground: tinycolor(theme.colors.primary.main).setAlpha(0.4).toString(), + highlightGutterBackground: tinycolor(theme.colors.primary.shade).setAlpha(0.2).toString(), + codeFoldGutterBackground: theme.colors.background.secondary, + codeFoldBackground: theme.colors.background.secondary, + emptyLineBackground: theme.colors.background.secondary, + gutterColor: theme.colors.text.disabled, + addedGutterColor: theme.colors.text.primary, + removedGutterColor: theme.colors.text.primary, + codeFoldContentColor: theme.colors.text.disabled, + diffViewerTitleBackground: theme.colors.background.secondary, + diffViewerTitleColor: theme.colors.text.disabled, + diffViewerTitleBorderColor: theme.colors.border.strong, + }, + }, + codeFold: { + fontSize: theme.typography.bodySmall.fontSize, + }, + gutter: ` + pre { + color: ${tinycolor(theme.colors.text.disabled).setAlpha(1).toString()}; + opacity: 0.61; + } + `, + }; + + return ( +
+ +
+ ); +}; diff --git a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts new file mode 100644 index 00000000000..488b7722f89 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts @@ -0,0 +1,75 @@ +import { createDashboardModelFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures'; + +import { HistorySrv } from './HistorySrv'; +import { restore, versions } from './__mocks__/dashboardHistoryMocks'; + +const getMock = jest.fn().mockResolvedValue({}); +const postMock = jest.fn().mockResolvedValue({}); + +jest.mock('app/core/store'); +jest.mock('@grafana/runtime', () => { + const original = jest.requireActual('@grafana/runtime'); + + return { + ...original, + getBackendSrv: () => ({ + post: postMock, + get: getMock, + }), + }; +}); + +describe('historySrv', () => { + const versionsResponse = versions(); + const restoreResponse = restore; + + let historySrv = new HistorySrv(); + + const dash = createDashboardModelFixture({ uid: '_U4zObQMz' }); + const emptyDash = createDashboardModelFixture(); + const historyListOpts = { limit: 10, start: 0 }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getHistoryList', () => { + it('should return a versions array for the given dashboard id', () => { + getMock.mockImplementation(() => Promise.resolve(versionsResponse)); + historySrv = new HistorySrv(); + + return historySrv.getHistoryList(dash.uid, historyListOpts).then((versions) => { + expect(versions).toEqual(versionsResponse); + }); + }); + + it('should return an empty array when not given an id', () => { + return historySrv.getHistoryList(emptyDash.uid, historyListOpts).then((versions) => { + expect(versions).toEqual([]); + }); + }); + + it('should return an empty array when not given a dashboard id', () => { + return historySrv.getHistoryList(null as unknown as string, historyListOpts).then((versions) => { + expect(versions).toEqual([]); + }); + }); + }); + + describe('restoreDashboard', () => { + it('should return a success response given valid parameters', () => { + const version = 6; + postMock.mockImplementation(() => Promise.resolve(restoreResponse(version))); + historySrv = new HistorySrv(); + return historySrv.restoreDashboard(dash, version).then((response) => { + expect(response).toEqual(restoreResponse(version)); + }); + }); + + it('should return an empty object when not given an id', async () => { + historySrv = new HistorySrv(); + const rsp = await historySrv.restoreDashboard(emptyDash, 6); + expect(rsp).toEqual({}); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts new file mode 100644 index 00000000000..f68390807c1 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts @@ -0,0 +1,50 @@ +import { isNumber } from 'lodash'; + +import { getBackendSrv } from '@grafana/runtime'; +import { DashboardModel } from 'app/features/dashboard/state'; + +export interface HistoryListOpts { + limit: number; + start: number; +} + +export interface RevisionsModel { + id: number; + checked: boolean; + dashboardUID: string; + parentVersion: number; + version: number; + created: Date; + createdBy: string; + message: string; +} + +export interface DiffTarget { + dashboardUID: string; + version: number; + unsavedDashboard?: DashboardModel; // when doing diffs against unsaved dashboard version +} + +export class HistorySrv { + getHistoryList(dashboardUID: string, options: HistoryListOpts) { + if (typeof dashboardUID !== 'string') { + return Promise.resolve([]); + } + + return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions`, options); + } + + getDashboardVersion(uid: string, version: number) { + return getBackendSrv().get(`api/dashboards/uid/${uid}/versions/${version}`); + } + + restoreDashboard(dashboard: DashboardModel, version: number) { + const uid = dashboard && dashboard.uid ? dashboard.uid : void 0; + const url = `api/dashboards/uid/${uid}/restore`; + + return uid && isNumber(version) ? getBackendSrv().post(url, { version }) : Promise.resolve({}); + } +} + +const historySrv = new HistorySrv(); +export { historySrv }; diff --git a/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx b/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx new file mode 100644 index 00000000000..e24c2155693 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx @@ -0,0 +1,34 @@ +import React, { useEffect } from 'react'; + +import { ConfirmModal } from '@grafana/ui'; + +import { useDashboardRestore } from './useDashboardRestore'; +export interface RevertDashboardModalProps { + hideModal: () => void; + version: number; +} + +export const RevertDashboardModal = ({ hideModal, version }: RevertDashboardModalProps) => { + // TODO: how should state.error be handled? + const { state, onRestoreDashboard } = useDashboardRestore(version); + + useEffect(() => { + if (!state.loading && state.value) { + hideModal(); + } + }, [state, hideModal]); + + return ( + Are you sure you want to restore the dashboard to version {version}? All unsaved changes will be lost.

+ } + confirmText={`Yes, restore to version ${version}`} + /> + ); +}; diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx new file mode 100644 index 00000000000..073b9c64d8d --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { Tooltip, Button, Stack } from '@grafana/ui'; + +type VersionsButtonsType = { + hasMore: boolean; + canCompare: boolean; + getVersions: (append: boolean) => void; + getDiff: () => void; + isLastPage: boolean; +}; +export const VersionsHistoryButtons = ({ + hasMore, + canCompare, + getVersions, + getDiff, + isLastPage, +}: VersionsButtonsType) => ( + + {hasMore && ( + + )} + + + + +); diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx new file mode 100644 index 00000000000..a2239fa60a2 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx @@ -0,0 +1,82 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, ModalsController, CollapsableSection, HorizontalGroup, useStyles2 } from '@grafana/ui'; + +import { DecoratedRevisionModel } from '../VersionsEditView'; + +import { DiffGroup } from './DiffGroup'; +import { DiffViewer } from './DiffViewer'; +import { RevertDashboardModal } from './RevertDashboardModal'; +import { jsonDiff } from './utils'; + +type DiffViewProps = { + isNewLatest: boolean; + newInfo: DecoratedRevisionModel; + baseInfo: DecoratedRevisionModel; + diffData: { lhs: string; rhs: string }; +}; + +export const VersionHistoryComparison = ({ baseInfo, newInfo, diffData, isNewLatest }: DiffViewProps) => { + const diff = jsonDiff(diffData.lhs, diffData.rhs); + const styles = useStyles2(getStyles); + + return ( +
+
+ +
+

+ Version {newInfo.version} updated by {newInfo.createdBy} {newInfo.ageString} -{' '} + {newInfo.message} +

+

+ Version {baseInfo.version} updated by {baseInfo.createdBy} {baseInfo.ageString} -{' '} + {baseInfo.message} +

+
+ {isNewLatest && ( + + {({ showModal, hideModal }) => ( + + )} + + )} +
+
+
+ {Object.entries(diff).map(([key, diffs]) => ( + + ))} +
+ + + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + spacer: css({ + 'margin-bottom': theme.spacing(4), + }), + versionInfo: css({ + color: theme.colors.text.secondary, + 'font-size': theme.typography.bodySmall.fontSize, + }), + noMarginBottom: css({ + 'margin-bottom': 0, + }), +}); diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx new file mode 100644 index 00000000000..ccfdaa6d17e --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx @@ -0,0 +1,41 @@ +import { css } from '@emotion/css'; +import { noop } from 'lodash'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, IconButton, useStyles2 } from '@grafana/ui'; + +type VersionHistoryHeaderProps = { + onClick?: () => void; + baseVersion?: number; + newVersion?: number; + isNewLatest?: boolean; +}; + +export const VersionHistoryHeader = ({ + onClick = noop, + baseVersion = 0, + newVersion = 0, + isNewLatest = false, +}: VersionHistoryHeaderProps) => { + const styles = useStyles2(getStyles); + + return ( +

+ + + Comparing {baseVersion} {newVersion}{' '} + {isNewLatest && (Latest)} + +

+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + header: css({ + 'font-size': theme.typography.h3.fontSize, + display: 'flex', + gap: theme.spacing(2), + 'margin-bottom': theme.spacing(3), + }), +}); diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx new file mode 100644 index 00000000000..b766bdac061 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx @@ -0,0 +1,73 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { Checkbox, Button, Tag, ModalsController } from '@grafana/ui'; + +import { DecoratedRevisionModel } from '../VersionsEditView'; + +import { RevertDashboardModal } from './RevertDashboardModal'; + +type VersionsTableProps = { + versions: DecoratedRevisionModel[]; + canCompare: boolean; + onCheck: (ev: React.FormEvent, versionId: number) => void; +}; + +export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsTableProps) => ( + + + + + + + + + + + + + {versions.map((version, idx) => ( + + + + + + + + + ))} + +
VersionDateUpdated byNotes
+ onCheck(ev, version.id)} + disabled={!version.checked && canCompare} + /> + {version.version}{version.createdDateString}{version.createdBy}{version.message} + {idx === 0 ? ( + + ) : ( + + {({ showModal, hideModal }) => ( + + )} + + )} +
+); diff --git a/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts b/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts new file mode 100644 index 00000000000..0de97c47a95 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts @@ -0,0 +1,176 @@ +export function versions() { + return [ + { + id: 4, + dashboardId: 1, + dashboardUID: '_U4zObQMz', + parentVersion: 3, + restoredFrom: 0, + version: 4, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 3, + dashboardId: 1, + dashboardUID: '_U4zObQMz', + parentVersion: 1, + restoredFrom: 1, + version: 3, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 2, + dashboardId: 1, + dashboardUID: '_U4zObQMz', + parentVersion: 0, + restoredFrom: -1, + version: 2, + created: '2017-02-22T17:29:52-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 1, + dashboardId: 1, + dashboardUID: '_U4zObQMz', + parentVersion: 0, + restoredFrom: -1, + slug: 'history-dashboard', + version: 1, + created: '2017-02-22T17:06:37-08:00', + createdBy: 'admin', + message: '', + }, + ]; +} + +export function restore(version: number, restoredFrom?: number) { + return { + dashboard: { + meta: { + type: 'db', + canSave: true, + canEdit: true, + canStar: true, + slug: 'history-dashboard', + expires: '0001-01-01T00:00:00Z', + created: '2017-02-21T18:40:45-08:00', + updated: '2017-04-11T21:31:22.59219665-07:00', + updatedBy: 'admin', + createdBy: 'admin', + version: version, + }, + dashboard: { + annotations: { + list: [], + }, + description: 'A random dashboard for implementing the history list', + editable: true, + gnetId: null, + graphTooltip: 0, + id: 1, + uid: '_U4zObQMz', + links: [], + restoredFrom: restoredFrom, + rows: [ + { + collapse: false, + height: '250px', + panels: [ + { + aliasColors: {}, + bars: false, + datasource: null, + fill: 1, + id: 1, + legend: { + avg: false, + current: false, + max: false, + min: false, + show: true, + total: false, + values: false, + }, + lines: true, + linewidth: 1, + nullPointMode: 'null', + percentage: false, + pointradius: 5, + points: false, + renderer: 'flot', + seriesOverrides: [], + span: 12, + stack: false, + steppedLine: false, + targets: [{}], + thresholds: [], + timeFrom: null, + timeShift: null, + title: 'Panel Title', + tooltip: { + shared: true, + sort: 0, + value_type: 'individual', + }, + type: 'graph', + xaxis: { + mode: 'time', + name: null, + show: true, + values: [], + }, + yaxes: [ + { + format: 'short', + label: null, + logBase: 1, + max: null, + min: null, + show: true, + }, + { + format: 'short', + label: null, + logBase: 1, + max: null, + min: null, + show: true, + }, + ], + }, + ], + repeat: null, + repeatIteration: null, + repeatRowId: null, + showTitle: false, + title: 'Dashboard Row', + titleSize: 'h6', + }, + ], + schemaVersion: 14, + tags: ['development'], + templating: { + list: [], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: { + refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], + time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'], + }, + timezone: 'utc', + title: 'History Dashboard', + version: version, + }, + }, + message: 'Dashboard restored to version ' + version, + version: version, + }; +} diff --git a/public/app/features/dashboard-scene/settings/version-history/index.ts b/public/app/features/dashboard-scene/settings/version-history/index.ts new file mode 100644 index 00000000000..c87d2d0b9b7 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/index.ts @@ -0,0 +1,5 @@ +export { HistorySrv, historySrv, RevisionsModel } from './HistorySrv'; +export { VersionHistoryTable } from './VersionHistoryTable'; +export { VersionHistoryHeader } from './VersionHistoryHeader'; +export { VersionsHistoryButtons } from './VersionHistoryButtons'; +export { VersionHistoryComparison } from './VersionHistoryComparison'; diff --git a/public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx b/public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx new file mode 100644 index 00000000000..447cd2fe392 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { useAsyncFn } from 'react-use'; + +import { locationUtil } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { useAppNotification } from 'app/core/copy/appNotification'; +import { DashboardModel } from 'app/features/dashboard/state'; +import { useSelector } from 'app/types'; + +import { dashboardWatcher } from '../../../live/dashboard/dashboardWatcher'; + +import { historySrv } from './HistorySrv'; + +const restoreDashboard = async (version: number, dashboard: DashboardModel) => { + // Skip the watcher logic for this save since it's handled by the hook + dashboardWatcher.ignoreNextSave(); + return await historySrv.restoreDashboard(dashboard, version); +}; + +export const useDashboardRestore = (version: number) => { + const dashboard = useSelector((state) => state.dashboard.getModel()); + const [state, onRestoreDashboard] = useAsyncFn(async () => await restoreDashboard(version, dashboard!), []); + const notifyApp = useAppNotification(); + + useEffect(() => { + if (state.value) { + const location = locationService.getLocation(); + const newUrl = locationUtil.stripBaseFromUrl(state.value.url); + const prevState = (location.state as any)?.routeReloadCounter; + locationService.replace({ + ...location, + pathname: newUrl, + state: { routeReloadCounter: prevState ? prevState + 1 : 1 }, + }); + notifyApp.success('Dashboard restored', `Restored from version ${version}`); + } + }, [state, version, notifyApp]); + return { state, onRestoreDashboard }; +}; diff --git a/public/app/features/dashboard-scene/settings/version-history/utils.test.ts b/public/app/features/dashboard-scene/settings/version-history/utils.test.ts new file mode 100644 index 00000000000..8d38c5cf6e9 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/utils.test.ts @@ -0,0 +1,295 @@ +import { Dashboard } from '@grafana/schema'; + +import { Diff, getDiffOperationText, getDiffText, jsonDiff } from './utils'; + +describe('getDiffOperationText', () => { + const cases = [ + ['add', 'added'], + ['remove', 'deleted'], + ['replace', 'changed'], + ['byDefault', 'changed'], + ]; + + test.each(cases)('it returns the correct verb for an operation', (operation, expected) => { + expect(getDiffOperationText(operation)).toBe(expected); + }); +}); + +type DiffTextCase = [Partial, string]; +describe('getDiffText', () => { + const addEmptyArray: DiffTextCase = [ + { op: 'add', value: [], path: ['annotations', 'list'], startLineNumber: 24 }, + 'added list', + ]; + const addArrayNumericProp: DiffTextCase = [ + { + op: 'add', + value: ['tag'], + path: ['panels', '3'], + }, + 'added item 3', + ]; + const addArrayProp: DiffTextCase = [ + { + op: 'add', + value: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }], + path: ['panels', '3', 'targets'], + }, + 'added 2 targets', + ]; + const addValueNumericProp: DiffTextCase = [ + { + op: 'add', + value: 'foo', + path: ['panels', '3'], + }, + 'added item 3', + ]; + const addValueProp: DiffTextCase = [ + { + op: 'add', + value: 'foo', + path: ['panels', '3', 'targets'], + }, + 'added targets', + ]; + + const removeEmptyArray: DiffTextCase = [ + { op: 'remove', originalValue: [], path: ['annotations', 'list'], startLineNumber: 24 }, + 'deleted list', + ]; + const removeArrayNumericProp: DiffTextCase = [ + { + op: 'remove', + originalValue: ['tag'], + path: ['panels', '3'], + }, + 'deleted item 3', + ]; + const removeArrayProp: DiffTextCase = [ + { + op: 'remove', + originalValue: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }], + path: ['panels', '3', 'targets'], + }, + 'deleted 2 targets', + ]; + const removeValueNumericProp: DiffTextCase = [ + { + op: 'remove', + originalValue: 'foo', + path: ['panels', '3'], + }, + 'deleted item 3', + ]; + const removeValueProp: DiffTextCase = [ + { + op: 'remove', + originalValue: 'foo', + path: ['panels', '3', 'targets'], + }, + 'deleted targets', + ]; + const replaceValueNumericProp: DiffTextCase = [ + { + op: 'replace', + originalValue: 'foo', + value: 'bar', + path: ['panels', '3'], + }, + 'changed item 3', + ]; + const replaceValueProp: DiffTextCase = [ + { + op: 'replace', + originalValue: 'foo', + value: 'bar', + path: ['panels', '3', 'targets'], + }, + 'changed targets', + ]; + + const cases = [ + addEmptyArray, + addArrayNumericProp, + addArrayProp, + addValueNumericProp, + addValueProp, + removeEmptyArray, + removeArrayNumericProp, + removeArrayProp, + removeValueNumericProp, + removeValueProp, + replaceValueNumericProp, + replaceValueProp, + ]; + + test.each(cases)( + 'returns a semantic message based on the type of diff, the values and the location of the change', + (diff: Partial, expected: string) => { + expect(getDiffText(diff as unknown as Diff)).toBe(expected); + } + ); +}); + +describe('jsonDiff', () => { + it('returns data related to each change', () => { + const lhs = { + annotations: { + list: [ + { + builtIn: 1, + datasource: '-- Grafana --', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + type: 'dashboard', + }, + ], + }, + editable: true, + gnetId: null, + graphTooltip: 0, + id: 141, + links: [], + panels: [], + schemaVersion: 27, + tags: [], + templating: { + list: [], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: {}, + timezone: '', + title: 'test dashboard', + uid: '_U4zObQMz', + version: 2, + }; + + const rhs = { + annotations: { + list: [ + { + builtIn: 1, + datasource: '-- Grafana --', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + type: 'dashboard', + }, + ], + }, + description: 'a description', + editable: true, + gnetId: null, + graphTooltip: 1, + id: 141, + links: [], + panels: [ + { + type: 'graph', + }, + ], + schemaVersion: 27, + tags: ['the tag'], + templating: { + list: [], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: { + refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'], + }, + timezone: 'utc', + title: 'My favourite dashboard', + uid: '_U4zObQMz', + version: 3, + }; + + const expected = { + description: [ + { + op: 'add', + originalValue: undefined, + path: ['description'], + startLineNumber: 14, + value: 'a description', + }, + ], + graphTooltip: [ + { + op: 'replace', + originalValue: 0, + path: ['graphTooltip'], + startLineNumber: 17, + value: 1, + }, + ], + panels: [ + { + op: 'add', + originalValue: undefined, + path: ['panels', '0'], + startLineNumber: 21, + value: { + type: 'graph', + }, + }, + ], + tags: [ + { + op: 'add', + originalValue: undefined, + path: ['tags', '0'], + startLineNumber: 27, + value: 'the tag', + }, + ], + timepicker: [ + { + op: 'add', + originalValue: undefined, + path: ['timepicker', 'refresh_intervals'], + startLineNumber: 37, + value: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'], + }, + ], + timezone: [ + { + op: 'replace', + originalValue: '', + path: ['timezone'], + startLineNumber: 51, + value: 'utc', + }, + ], + title: [ + { + op: 'replace', + originalValue: 'test dashboard', + path: ['title'], + startLineNumber: 52, + value: 'My favourite dashboard', + }, + ], + version: [ + { + op: 'replace', + originalValue: 2, + path: ['version'], + startLineNumber: 54, + value: 3, + }, + ], + }; + + expect(jsonDiff(lhs as unknown as Dashboard, rhs as unknown as Dashboard)).toStrictEqual(expected); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/version-history/utils.ts b/public/app/features/dashboard-scene/settings/version-history/utils.ts new file mode 100644 index 00000000000..ac036e2acae --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/utils.ts @@ -0,0 +1,104 @@ +import { compare, Operation } from 'fast-json-patch'; +// @ts-ignore +import jsonMap from 'json-source-map'; +import { flow, get, isArray, isEmpty, last, sortBy, tail, toNumber, isNaN } from 'lodash'; + +import { Dashboard } from '@grafana/schema'; + +export type Diff = { + op: 'add' | 'replace' | 'remove' | 'copy' | 'test' | '_get' | 'move'; + value: unknown; + originalValue: unknown; + path: string[]; + startLineNumber: number; +}; + +export type Diffs = { + [key: string]: Diff[]; +}; + +export type JSONValue = string | Dashboard; + +export const jsonDiff = (lhs: JSONValue, rhs: JSONValue): Diffs => { + const diffs = compare(lhs, rhs); + const lhsMap = jsonMap.stringify(lhs, null, 2); + const rhsMap = jsonMap.stringify(rhs, null, 2); + + const getDiffInformation = (diffs: Operation[]): Diff[] => { + return diffs.map((diff) => { + let originalValue = undefined; + let value = undefined; + let startLineNumber = 0; + + const path = tail(diff.path.split('/')); + + if (diff.op === 'replace' && rhsMap.pointers[diff.path]) { + originalValue = get(lhs, path); + value = diff.value; + startLineNumber = rhsMap.pointers[diff.path].value.line; + } + if (diff.op === 'add' && rhsMap.pointers[diff.path]) { + value = diff.value; + startLineNumber = rhsMap.pointers[diff.path].value.line; + } + if (diff.op === 'remove' && lhsMap.pointers[diff.path]) { + originalValue = get(lhs, path); + startLineNumber = lhsMap.pointers[diff.path].value.line; + } + + return { + op: diff.op, + value, + path, + originalValue, + startLineNumber, + }; + }); + }; + + const sortByLineNumber = (diffs: Diff[]) => sortBy(diffs, 'startLineNumber'); + const groupByPath = (diffs: Diff[]) => + diffs.reduce>((acc, value) => { + const groupKey: string = value.path[0]; + if (!acc[groupKey]) { + acc[groupKey] = []; + } + acc[groupKey].push(value); + return acc; + }, {}); + + return flow([getDiffInformation, sortByLineNumber, groupByPath])(diffs); +}; + +export const getDiffText = (diff: Diff, showProp = true) => { + const prop = last(diff.path)!; + const propIsNumeric = isNumeric(prop); + const val = diff.op === 'remove' ? diff.originalValue : diff.value; + let text = getDiffOperationText(diff.op); + + if (showProp) { + if (propIsNumeric) { + text += ` item ${prop}`; + } else { + if (isArray(val) && !isEmpty(val)) { + text += ` ${val.length} ${prop}`; + } else { + text += ` ${prop}`; + } + } + } + + return text; +}; + +const isNumeric = (value: string) => !isNaN(toNumber(value)); + +export const getDiffOperationText = (operation: string): string => { + if (operation === 'add') { + return 'added'; + } + if (operation === 'remove') { + return 'deleted'; + } + return 'changed'; +}; diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx index d2b13040688..bd4585b2c15 100644 --- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx @@ -63,7 +63,7 @@ export class VersionsSettings extends PureComponent { getVersions = (append = false) => { this.setState({ isAppending: append }); historySrv - .getHistoryList(this.props.dashboard, { limit: this.limit, start: this.start }) + .getHistoryList(this.props.dashboard.uid, { limit: this.limit, start: this.start }) .then((res) => { this.setState({ isLoading: false, @@ -186,7 +186,7 @@ export class VersionsSettings extends PureComponent { } } -const VersionsHistorySpinner = ({ msg }: { msg: string }) => ( +export const VersionsHistorySpinner = ({ msg }: { msg: string }) => ( {msg} diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts index 8571795c054..ab89796f738 100644 --- a/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts +++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts @@ -1,4 +1,3 @@ -import { DashboardModel } from '../../state/DashboardModel'; import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures'; import { HistorySrv } from './HistorySrv'; @@ -39,19 +38,19 @@ describe('historySrv', () => { getMock.mockImplementation(() => Promise.resolve(versionsResponse)); historySrv = new HistorySrv(); - return historySrv.getHistoryList(dash, historyListOpts).then((versions) => { + return historySrv.getHistoryList(dash.uid, historyListOpts).then((versions) => { expect(versions).toEqual(versionsResponse); }); }); it('should return an empty array when not given an id', () => { - return historySrv.getHistoryList(emptyDash, historyListOpts).then((versions) => { + return historySrv.getHistoryList(emptyDash.uid, historyListOpts).then((versions) => { expect(versions).toEqual([]); }); }); - it('should return an empty array when not given a dashboard', () => { - return historySrv.getHistoryList(null as unknown as DashboardModel, historyListOpts).then((versions) => { + it('should return an empty array when not given a dashboard id', () => { + return historySrv.getHistoryList(null as unknown as string, historyListOpts).then((versions) => { expect(versions).toEqual([]); }); }); diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts index 351882ac6dc..bb3aaec7abe 100644 --- a/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts +++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts @@ -27,9 +27,12 @@ export interface DiffTarget { } export class HistorySrv { - getHistoryList(dashboard: DashboardModel, options: HistoryListOpts) { - const uid = dashboard && dashboard.uid ? dashboard.uid : void 0; - return uid ? getBackendSrv().get(`api/dashboards/uid/${uid}/versions`, options) : Promise.resolve([]); + getHistoryList(dashboardUID: string, options: HistoryListOpts) { + if (typeof dashboardUID !== 'string') { + return Promise.resolve([]); + } + + return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions`, options); } getDashboardVersion(uid: string, version: number) {