diff --git a/.betterer.results b/.betterer.results index bc2be1dd6ae..9bdbb599b09 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1197,9 +1197,6 @@ exports[`better eslint`] = { "public/app/core/components/TagFilter/TagOption.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], - "public/app/core/components/TimePicker/TimePickerWithHistory.tsx:5381": [ - [0, 0, 0, "\'@grafana/ui/src/utils/i18n\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] - ], "public/app/core/components/TimeSeries/TimeSeries.tsx:5381": [ [0, 0, 0, "\'@grafana/ui/src/components/uPlot/PlotLegend\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], [0, 0, 0, "\'@grafana/ui/src/components/uPlot/config/UPlotConfigBuilder\' import is restricted from being used by a pattern. Import from the public export instead.", "1"], @@ -1561,19 +1558,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"], [0, 0, 0, "No untranslated strings. Wrap text with ", "4"] ], - "public/app/features/alerting/unified/CloneRuleEditor.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"] - ], - "public/app/features/alerting/unified/ExistingRuleEditor.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] - ], "public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx:5381": [ [0, 0, 0, "\'@grafana/data/src/datetime/rangeutil\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], @@ -1612,12 +1596,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], [0, 0, 0, "No untranslated strings. Wrap text with ", "7"] ], - "public/app/features/alerting/unified/RuleEditor.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] - ], "public/app/features/alerting/unified/RuleViewer.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], @@ -2502,12 +2480,11 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], "public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx:5381": [ - [0, 0, 0, "\'@grafana/ui/src/utils/i18n\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "4"] ], "public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], @@ -2570,7 +2547,8 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], [0, 0, 0, "No untranslated strings. Wrap text with ", "8"], [0, 0, 0, "No untranslated strings. Wrap text with ", "9"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "10"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "10"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "11"] ], "public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], @@ -2932,6 +2910,25 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx:5381": [ [0, 0, 0, "\'@grafana/ui/src/components/Icon/utils\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] ], + "public/app/features/alerting/unified/rule-editor/CloneRuleEditor.tsx:5381": [ + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"] + ], + "public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx:5381": [ + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] + ], + "public/app/features/alerting/unified/rule-editor/RuleEditor.tsx:5381": [ + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] + ], "public/app/features/alerting/unified/rule-list/FilterView.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], @@ -3758,8 +3755,7 @@ exports[`better eslint`] = { ], "public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/UpsertSnapshot.tsx:5381": [ [0, 0, 0, "\'@grafana/ui/src/components/Input/Input\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], - [0, 0, 0, "\'@grafana/ui/src/utils/i18n\' import is restricted from being used by a pattern. Import from the public export instead.", "1"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"] + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"] ], "public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawerConfirmAction.tsx:5381": [ [0, 0, 0, "\'@grafana/ui/src/components/ConfirmModal/ConfirmContent\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] @@ -4260,9 +4256,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/dashboard/services/TimeSrv.ts:5381": [ - [0, 0, 0, "\'@grafana/ui/src/utils/i18n\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] - ], "public/app/features/dashboard/state/DashboardMigrator.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -4684,9 +4677,8 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], "public/app/features/explore/ExploreRunQueryButton.tsx:5381": [ - [0, 0, 0, "\'@grafana/ui/src/utils/i18n\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"] + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"] ], "public/app/features/explore/ExploreToolbar.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], @@ -5093,9 +5085,6 @@ exports[`better eslint`] = { "public/app/features/explore/state/time.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/explore/state/time.ts:5381": [ - [0, 0, 0, "\'@grafana/ui/src/utils/i18n\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] - ], "public/app/features/explore/state/utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -6161,9 +6150,6 @@ exports[`better eslint`] = { "public/app/features/search/utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx:5381": [ - [0, 0, 0, "\'@grafana/ui/src/utils/i18n\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] - ], "public/app/features/serviceaccounts/ServiceAccountPage.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], @@ -6364,8 +6350,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] ], "public/app/features/trails/DataTrailSettings.tsx:5381": [ - [0, 0, 0, "\'@grafana/ui/src/utils/i18n\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], "public/app/features/trails/DataTrailsHistory.tsx:5381": [ [0, 0, 0, "\'@grafana/data/src/datetime/rangeutil\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], @@ -6392,9 +6377,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], [0, 0, 0, "No untranslated strings. Wrap text with ", "8"] ], - "public/app/features/trails/MetricSelect/NativeHistogramBadge.tsx:5381": [ - [0, 0, 0, "\'@grafana/ui/src/utils/i18n\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] - ], "public/app/features/trails/MetricsHeader.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] @@ -6408,8 +6390,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] ], "public/app/features/trails/banners/NativeHistogramBanner.tsx:5381": [ - [0, 0, 0, "\'@grafana/ui/src/utils/i18n\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"] + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], "public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx:5381": [ [0, 0, 0, "\'@grafana/data/src/transformations/transformers/filterByValue\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], @@ -7760,8 +7741,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/app/plugins/datasource/zipkin/datasource.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"] ], "public/app/plugins/datasource/zipkin/utils/transforms.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] diff --git a/.gitignore b/.gitignore index ee7dc7d9387..51c6b92c672 100644 --- a/.gitignore +++ b/.gitignore @@ -179,7 +179,6 @@ compilation-stats.json /blob-report/ /playwright/.cache/ /playwright/.auth/ - # grafana server /scripts/grafana-server/server.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb3ea46a8e..c1c7060ba7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,118 @@ + + +# 11.4.1 (2025-01-28) + +### Features and enhancements + +- **Security:** Update to Go 1.23.5 - Backport to v11.4.x [#99123](https://github.com/grafana/grafana/pull/99123), [@Proximyst](https://github.com/Proximyst) +- **Security:** Update to Go 1.23.5 - Backport to v11.4.x (Enterprise) + +### Bug fixes + +- **Alerting:** AlertingQueryRunner should skip descendant nodes of invalid queries [#97830](https://github.com/grafana/grafana/pull/97830), [@gillesdemey](https://github.com/gillesdemey) +- **Alerting:** Fix alert rules unpausing after moving rule to different folder [#97583](https://github.com/grafana/grafana/pull/97583), [@santihernandezc](https://github.com/santihernandezc) +- **Alerting:** Fix label escaping in rule export [#98649](https://github.com/grafana/grafana/pull/98649), [@moustafab](https://github.com/moustafab) +- **Alerting:** Fix slack image uploading to use new api [#98066](https://github.com/grafana/grafana/pull/98066), [@moustafab](https://github.com/moustafab) +- **Azure/GCM:** Improve error display [#97594](https://github.com/grafana/grafana/pull/97594), [@aangelisc](https://github.com/aangelisc) +- **Dashboards:** Fix issue where filtered panels would not react to variable changes [#98734](https://github.com/grafana/grafana/pull/98734), [@oscarkilhed](https://github.com/oscarkilhed) +- **Dashboards:** Fixes issue with panel header showing even when hide time override was enabled [#98747](https://github.com/grafana/grafana/pull/98747), [@torkelo](https://github.com/torkelo) +- **Dashboards:** Fixes week relative time ranges when weekStart was changed [#98269](https://github.com/grafana/grafana/pull/98269), [@torkelo](https://github.com/torkelo) +- **Dashboards:** Panel react for `timeFrom` and `timeShift` changes using variables [#98659](https://github.com/grafana/grafana/pull/98659), [@Sergej-Vlasov](https://github.com/Sergej-Vlasov) +- **DateTimePicker:** Fixes issue with date picker showing invalid date [#97971](https://github.com/grafana/grafana/pull/97971), [@torkelo](https://github.com/torkelo) +- **Fix:** Add support for datasource variable queries [#98119](https://github.com/grafana/grafana/pull/98119), [@sunker](https://github.com/sunker) +- **InfluxDB:** Adhoc filters can use template vars as values [#98786](https://github.com/grafana/grafana/pull/98786), [@bossinc](https://github.com/bossinc) +- **LibraryPanel:** Fallback to panel title if library panel title is not set [#99410](https://github.com/grafana/grafana/pull/99410), [@ivanortegaalba](https://github.com/ivanortegaalba) + +### Plugin development fixes & changes + +- **Grafana UI:** Re-add react-router-dom as a dependency [#98422](https://github.com/grafana/grafana/pull/98422), [@leventebalogh](https://github.com/leventebalogh) + + + + +# 11.3.3 (2025-01-28) + +### Features and enhancements + +- **Azure Monitor:** Add a feature flag to toggle user auth for Azure Monitor only [#97576](https://github.com/grafana/grafana/pull/97576), [@adamyeats](https://github.com/adamyeats) +- **Security:** Update to Go 1.23.5 - Backport to v11.3.x [#99124](https://github.com/grafana/grafana/pull/99124), [@Proximyst](https://github.com/Proximyst) +- **Security:** Update to Go 1.23.5 - Backport to v11.3.x (Enterprise) + +### Bug fixes + +- **Alerting:** AlertingQueryRunner should skip descendant nodes of invalid queries [#97829](https://github.com/grafana/grafana/pull/97829), [@gillesdemey](https://github.com/gillesdemey) +- **Azure/GCM:** Improve error display [#97593](https://github.com/grafana/grafana/pull/97593), [@aangelisc](https://github.com/aangelisc) +- **Dashboard:** Fixes issue with compatability of old DashboardModel.annotations [#97467](https://github.com/grafana/grafana/pull/97467), [@torkelo](https://github.com/torkelo) +- **Dashboards:** Fix issue where filtered panels would not react to variable changes [#98733](https://github.com/grafana/grafana/pull/98733), [@oscarkilhed](https://github.com/oscarkilhed) +- **Dashboards:** Fixes issue with panel header showing even when hide time override was enabled [#97389](https://github.com/grafana/grafana/pull/97389), [@torkelo](https://github.com/torkelo) +- **Dashboards:** Fixes week relative time ranges when weekStart was changed [#98268](https://github.com/grafana/grafana/pull/98268), [@torkelo](https://github.com/torkelo) +- **DateTimePicker:** Fixes issue with date picker showing invalid date [#97970](https://github.com/grafana/grafana/pull/97970), [@torkelo](https://github.com/torkelo) +- **Fix:** Add support for datasource variable queries [#98118](https://github.com/grafana/grafana/pull/98118), [@sunker](https://github.com/sunker) +- **InfluxDB:** Adhoc filters can use template vars as values [#98785](https://github.com/grafana/grafana/pull/98785), [@bossinc](https://github.com/bossinc) +- **Unified Storage:** Use tls preferred when grafana db using ssl [#97379](https://github.com/grafana/grafana/pull/97379), [@owensmallwood](https://github.com/owensmallwood) + +### Plugin development fixes & changes + +- **Grafana UI:** Re-add react-router-dom as a dependency [#98421](https://github.com/grafana/grafana/pull/98421), [@leventebalogh](https://github.com/leventebalogh) + + + + +# 11.2.6 (2025-01-28) + +### Features and enhancements + +- **Azure Monitor:** Add a feature flag to toggle user auth for Azure Monitor only [#97565](https://github.com/grafana/grafana/pull/97565), [@adamyeats](https://github.com/adamyeats) +- **Security:** Update to Go 1.22.11 - Backport to v11.2.x [#99125](https://github.com/grafana/grafana/pull/99125), [@Proximyst](https://github.com/Proximyst) +- **Security:** Update to Go 1.22.11 - Backport to v11.2.x (Enterprise) + +### Bug fixes + +- **Azure/GCM:** Improve error display [#97591](https://github.com/grafana/grafana/pull/97591), [@aangelisc](https://github.com/aangelisc) + + + + +# 11.1.11 (2025-01-28) + +### Features and enhancements + +- **Security:** Update to Go 1.22.11 - Backport to v11.1.x [#99126](https://github.com/grafana/grafana/pull/99126), [@Proximyst](https://github.com/Proximyst) +- **Security:** Update to Go 1.22.11 - Backport to v11.1.x (Enterprise) + +### Bug fixes + +- **Azure/GCM:** Improve error display [#97595](https://github.com/grafana/grafana/pull/97595), [@aangelisc](https://github.com/aangelisc) + + + + +# 11.0.10 (2025-01-28) + +### Features and enhancements + +- **Security:** Update to Go 1.22.11 - Backport to v11.0.x [#99127](https://github.com/grafana/grafana/pull/99127), [@Proximyst](https://github.com/Proximyst) +- **Security:** Update to Go 1.22.11 - Backport to v11.0.x (Enterprise) + +### Bug fixes + +- **Azure/GCM:** Improve error display [#97592](https://github.com/grafana/grafana/pull/97592), [@aangelisc](https://github.com/aangelisc) + + + + +# 10.4.15 (2025-01-28) + +### Features and enhancements + +- **Security:** Update to Go 1.22.11 - Backport to v10.4.x [#99128](https://github.com/grafana/grafana/pull/99128), [@Proximyst](https://github.com/Proximyst) +- **Security:** Update to Go 1.22.11 - Backport to v10.4.x (Enterprise) + +### Bug fixes + +- **Azure/GCM:** Improve error display [#97590](https://github.com/grafana/grafana/pull/97590), [@aangelisc](https://github.com/aangelisc) + + # 11.4.0 (2024-12-05) diff --git a/conf/defaults.ini b/conf/defaults.ini index 88350cfa658..cb880130cef 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -459,6 +459,9 @@ min_refresh_interval = 5s # Path to the default home dashboard. If this value is empty, then Grafana uses StaticRootPath + "dashboards/home.json" default_home_dashboard_path = +# Dashboards UIDs to report performance metrics for. * can be used to report metrics for all dashboards +dashboard_performance_metrics = + ################################### Data sources ######################### [datasources] # Upper limit of data sources that Grafana will return. This limit is a temporary configuration and it will be deprecated when pagination will be introduced on the list data sources API. diff --git a/docs/sources/administration/enterprise-licensing/_index.md b/docs/sources/administration/enterprise-licensing/_index.md index e42696f97ec..b532bcf74db 100644 --- a/docs/sources/administration/enterprise-licensing/_index.md +++ b/docs/sources/administration/enterprise-licensing/_index.md @@ -246,6 +246,14 @@ Your license is controlled by the following rules: As the license expiration date approaches, you will see a banner in Grafana that encourages you to renew. To learn about how to renew your license and what happens in Grafana when a license expires, refer to [License expiration]({{< relref "#license-expiration" >}}). +**License token expiration:** Your license must contain a valid token, which renews periodically. + +A license token is a digital key that activates your license. By default, license tokens renew every 7 days by calling the Grafana.com API. Short-lived license tokens enable more frequent validation that licenses are compliant, and allow for more frequent license updates - for example, adding users or invalidating a compromised license. + +To view the details of your license token, sign in to Grafana Enterprise as a Server Administrator and visit **Administration** > **General** > **Statistics and licensing**. Token details are in the Token section under License Details. + +License token renewal requires internet access, and requires that the `auto_refresh_license` [configuration setting](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/enterprise-configuration/#auto_refresh_license) be set to `true`. If your Grafana Enterprise instance cannot connect to the internet, contact your Grafana Labs account team for additional options for token renewal and license audit. + **Grafana License URL:** Your license does not work with an instance of Grafana with a different root URL. The License URL is the complete URL of your Grafana instance, for example `https://grafana.your-company.com/`. It is defined in the [root_url]({{< relref "../../setup-grafana/configure-grafana/#root_url" >}}) configuration setting. @@ -264,7 +272,7 @@ For instructions about how to activate your license after it is updated, refer t ## Usage billing -Standard Grafana Enterprise licenses include a certain number of seats that can be used, and prevent more users logging into Grafana than have been licensed. This makes sense if you prefer a predictable bill. It can however be a problem if you anticipate uneven usage patterns over time or when it's critical that no user ever be prevented from logging into Grafana due to capacity constraints. +Standard Grafana Enterprise licenses include a certain number of seats that can be used, and prevent more users logging into Grafana than have been licensed. This makes sense if you prefer a predictable bill. It can however be a problem if you anticipate uneven usage patterns over time or when it's critical that no user ever be prevented from logging in to Grafana due to capacity constraints. For those use-cases we support usage-based billing, where your license includes a certain number of included users and you are billed on a monthly basis for any excess active users during the month. diff --git a/docs/sources/alerting/fundamentals/alert-rules/queries-conditions.md b/docs/sources/alerting/fundamentals/alert-rules/queries-conditions.md index 229816db306..877d1b5e76a 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/queries-conditions.md +++ b/docs/sources/alerting/fundamentals/alert-rules/queries-conditions.md @@ -103,7 +103,7 @@ The threshold expression allows you to compare two single values. It returns `0` - Is above (x > y) - Is below (x < y) - Is within range (x > y1 AND x < y2) -- Is outside range (x < y1 AND x > y2) +- Is outside range (x < y1 OR x > y2) **Classic condition (legacy)** 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 3038e1624b3..fecc19478b7 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -81,7 +81,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `userStorageAPI` | Enables the user storage API | Yes | | `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | | | `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes | -| `zipkinBackendMigration` | Enables querying Zipkin data source without the proxy | Yes | | `reportingUseRawTimeRange` | Uses the original report or dashboard time range instead of making an absolute transformation | Yes | | `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes | | `azureMonitorEnableUserAuth` | Enables user auth for Azure Monitor datasource only | Yes | diff --git a/docs/sources/upgrade-guide/upgrade-v11.5/index.md b/docs/sources/upgrade-guide/upgrade-v11.5/index.md new file mode 100644 index 00000000000..633dfa3b7e0 --- /dev/null +++ b/docs/sources/upgrade-guide/upgrade-v11.5/index.md @@ -0,0 +1,22 @@ +--- +description: Guide for upgrading to Grafana v11.5 +keywords: + - grafana + - configuration + - documentation + - upgrade + - '11.5' +title: Upgrade to Grafana v11.5 +menuTitle: Upgrade to v11.5 +weight: 700 +--- + +# Upgrade to Grafana v11.5 + +{{< docs/shared lookup="upgrade/intro.md" source="grafana" version="" >}} + +{{< docs/shared lookup="back-up/back-up-grafana.md" source="grafana" version="" leveloffset="+1" >}} + +{{< docs/shared lookup="upgrade/upgrade-common-tasks.md" source="grafana" version="" >}} + +## Technical notes diff --git a/docs/sources/whatsnew/_index.md b/docs/sources/whatsnew/_index.md index 0de15d17301..db397d8a537 100644 --- a/docs/sources/whatsnew/_index.md +++ b/docs/sources/whatsnew/_index.md @@ -76,6 +76,7 @@ For a complete list of every change, with links to pull requests and related iss ## Grafana 11 +- [What's new in 11.5](https://grafana.com/docs/grafana//whatsnew/whats-new-in-v11-5/) - [What's new in 11.4](https://grafana.com/docs/grafana//whatsnew/whats-new-in-v11-4/) - [What's new in 11.3](https://grafana.com/docs/grafana//whatsnew/whats-new-in-v11-3/) - [What's new in 11.2](https://grafana.com/docs/grafana//whatsnew/whats-new-in-v11-2/) diff --git a/docs/sources/whatsnew/whats-new-in-v11-5.md b/docs/sources/whatsnew/whats-new-in-v11-5.md new file mode 100644 index 00000000000..f532c4a82cb --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v11-5.md @@ -0,0 +1,62 @@ +--- +description: Feature and improvement highlights for Grafana v11.5 +keywords: + - grafana + - new + - documentation + - '11.5' + - release notes +labels: +products: + - cloud + - enterprise + - oss +title: What's new in Grafana v11.5 +posts: + - title: Cloud Migration Assistant + items: + - docs/grafana-cloud/whats-new/2025-01-10-grafana-cloud-migration-assistant-supports-all-plugins-and-grafana-alerting.md + - title: Dashboards and visualizations + items: + - docs/grafana-cloud/whats-new/2024-10-16-redesigned-ad-hoc-filters-for-dashboards.md + - docs/grafana-cloud/whats-new/2024-11-19-new-regular-expression-option-for-extract-fields-transformation.md + - docs/grafana-cloud/whats-new/2024-09-04-sharing-drawer.md + - docs/grafana-cloud/whats-new/2024-12-16-customizable-shareable-dashboard-panel-images.md + - title: Reporting + items: + - docs/grafana-cloud/whats-new/2024-10-21-theme-options-for-reporting.md + - docs/grafana-cloud/whats-new/2024-12-02-pdf-export-improvements-in-ga.md + - title: Alerting + items: + - docs/grafana-cloud/whats-new/2025-01-22-rbac-for-alerting-notifications.md + - docs/grafana-cloud/whats-new/2025-01-22-rbac-for-notification-policies.md + - title: Data sources + items: + - docs/grafana-cloud/whats-new/2025-01-09-elasticsearch-cross-cluster-search-support.md + - docs/grafana-cloud/whats-new/2024-11-12-open-search-datasource-now-supports-private-datasource-connect.md + - docs/grafana-cloud/whats-new/2024-12-04-time-series-macro-support-in-visual-query-builder-for-sql-data-sources.md + - title: Authentication and authorization + items: + - docs/grafana-cloud/whats-new/2025-01-07-oauth-and-saml-session-handling-improvements.md + - title: Plugins + items: + - docs/grafana-cloud/whats-new/2025-01-10-plugin-frontend-sandbox.md + - title: Public dashboards + items: + - docs/grafana-cloud/whats-new/2024-09-09-public-dashboards-are-now-shared-dashboards.md +whats_new_grafana_version: 11.5 +weight: -47 +--- + +# What’s new in Grafana v11.5 + +Welcome to Grafana 11.5! +Read on to learn about new sharing, reporting, and export options, cross-cluster search for Elasticsearch, PDC support for several new data sources, and more. +The Grafana Cloud Migration Assistant is in public preview and now supports all plugins and Grafana Alerts, in addition to dashboards, folders, and data sources. +We've also made it more secure to run third-party apps and data sources, and improved user session handling for OAuth 2.0 and SAML. + +{{< youtube id="RGiktzfhRd0" >}} + +For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). For the specific steps we recommend when you upgrade to v11.5, check out our [Upgrade Guide](https://grafana.com/docs/grafana//upgrade-guide/upgrade-v11.5/). + +{{< docs/whats-new >}} diff --git a/package.json b/package.json index 8d523b8b453..e5a177da104 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "e2e:enterprise": "./e2e/start-and-run-suite enterprise", "e2e:enterprise:dev": "./e2e/start-and-run-suite enterprise dev", "e2e:enterprise:debug": "./e2e/start-and-run-suite enterprise debug", + "build-benchmark": "NODE_ENV=dev nx exec -- webpack --config scripts/webpack/webpack.dev.js --env benchmark=1", + "e2e:playwright:benchmark": "yarn build-benchmark && ./e2e/plugin-e2e/start-and-benchmark", "e2e:playwright": "yarn playwright test", "e2e:playwright:server": "yarn e2e:plugin:build && ./e2e/plugin-e2e/start-and-run-suite", "e2e:storybook": "PORT=9001 ./e2e/run-suite storybook true", @@ -120,10 +122,10 @@ "@types/jquery": "3.5.32", "@types/js-yaml": "^4.0.5", "@types/jsurl": "^1.2.28", - "@types/lodash": "4.17.14", + "@types/lodash": "4.17.15", "@types/logfmt": "^1.2.3", "@types/lucene": "^2", - "@types/node": "22.10.10", + "@types/node": "22.12.0", "@types/node-forge": "^1", "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4", "@types/pluralize": "^0.0.33", @@ -145,7 +147,7 @@ "@types/slate": "0.47.11", "@types/slate-plain-serializer": "0.7.5", "@types/slate-react": "0.22.9", - "@types/swagger-ui-react": "4.18.3", + "@types/swagger-ui-react": "4.19.0", "@types/systemjs": "6.15.1", "@types/tinycolor2": "1.4.6", "@types/uuid": "10.0.0", @@ -271,8 +273,8 @@ "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/saga-icons": "workspace:*", - "@grafana/scenes": "5.38.0", - "@grafana/scenes-react": "5.38.0", + "@grafana/scenes": "5.41.0", + "@grafana/scenes-react": "5.41.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", @@ -349,7 +351,7 @@ "ml-regression-polynomial": "^3.0.0", "ml-regression-simple-linear": "^3.0.0", "moment": "2.30.1", - "moment-timezone": "0.5.46", + "moment-timezone": "0.5.47", "monaco-editor": "0.34.1", "moveable": "0.53.0", "nanoid": "^5.0.4", @@ -400,7 +402,7 @@ "slate": "0.47.9", "slate-plain-serializer": "0.7.13", "slate-react": "0.22.10", - "swagger-ui-react": "5.18.2", + "swagger-ui-react": "5.18.3", "symbol-observable": "4.0.0", "systemjs": "6.15.1", "systemjs-cjs-extra": "0.2.1", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index db7b6f85e1c..2d5576fa453 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -49,9 +49,9 @@ "marked": "15.0.6", "marked-mangle": "1.1.10", "moment": "2.30.1", - "moment-timezone": "0.5.46", + "moment-timezone": "0.5.47", "ol": "7.4.0", - "papaparse": "5.5.1", + "papaparse": "5.5.2", "react-use": "17.6.0", "rxjs": "7.8.1", "string-hash": "^1.1.3", @@ -64,8 +64,8 @@ "@grafana/tsconfig": "^2.0.0", "@rollup/plugin-node-resolve": "16.0.0", "@types/history": "4.7.11", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/papaparse": "5.3.15", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", diff --git a/packages/grafana-data/src/themes/createTheme.ts b/packages/grafana-data/src/themes/createTheme.ts index a224f1be11b..554e567c7c4 100644 --- a/packages/grafana-data/src/themes/createTheme.ts +++ b/packages/grafana-data/src/themes/createTheme.ts @@ -41,7 +41,7 @@ export function createTheme(options: NewThemeOptions = {}): GrafanaTheme2 { const visualization = createVisualizationColors(colors); const theme = { - name: (name ?? colors.mode === 'dark') ? 'Dark' : 'Light', + name: name ?? (colors.mode === 'dark' ? 'Dark' : 'Light'), isDark: colors.mode === 'dark', isLight: colors.mode === 'light', colors, diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index b1f6ca4b28a..560f4ba672f 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -228,6 +228,7 @@ export interface GrafanaConfig { rudderstackConfigUrl: string | undefined; rudderstackIntegrationsUrl: string | undefined; analyticsConsoleReporting: boolean; + dashboardPerformanceMetrics: string[]; sqlConnectionLimits: SqlConnectionLimits; sharedWithMeFolderUID?: string; rootFolderUID?: string; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 04d84ee202e..0a6775edac1 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -229,7 +229,6 @@ export interface FeatureToggles { exploreMetricsRelatedLogs?: boolean; prometheusSpecialCharsInLabelValues?: boolean; enableExtensionsAdminPage?: boolean; - zipkinBackendMigration?: boolean; enableSCIM?: boolean; crashDetection?: boolean; jaegerBackendMigration?: boolean; diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index f6da48b7630..5eaf8277ad1 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@rollup/plugin-node-resolve": "16.0.0", - "@types/node": "22.10.10", + "@types/node": "22.12.0", "@types/semver": "7.5.8", "esbuild": "0.24.2", "rimraf": "6.0.1", diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 0ff1830c8a2..e48e6113c6b 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -67,8 +67,8 @@ "@testing-library/user-event": "14.5.2", "@types/d3": "^7", "@types/jest": "^29.5.4", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/react": "18.3.18", "@types/react-virtualized-auto-sizer": "1.0.4", "@types/tinycolor2": "1.4.6", diff --git a/packages/grafana-icons/package.json b/packages/grafana-icons/package.json index 5e4ea1556f8..a39c28777a5 100644 --- a/packages/grafana-icons/package.json +++ b/packages/grafana-icons/package.json @@ -45,7 +45,7 @@ "@svgr/plugin-prettier": "^8.1.0", "@svgr/plugin-svgo": "^8.1.0", "@types/babel__core": "^7", - "@types/node": "22.10.10", + "@types/node": "22.12.0", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "esbuild": "0.24.2", diff --git a/packages/grafana-o11y-ds-frontend/package.json b/packages/grafana-o11y-ds-frontend/package.json index fbd1ee55713..a668fa0280b 100644 --- a/packages/grafana-o11y-ds-frontend/package.json +++ b/packages/grafana-o11y-ds-frontend/package.json @@ -20,7 +20,7 @@ "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", "@grafana/e2e-selectors": "11.5.0-pre", - "@grafana/experimental": "2.1.6", + "@grafana/plugin-ui": "0.9.6", "@grafana/runtime": "11.5.0-pre", "@grafana/schema": "11.5.0-pre", "@grafana/ui": "11.5.0-pre", @@ -36,7 +36,7 @@ "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", "@types/jest": "^29.5.4", - "@types/node": "22.10.10", + "@types/node": "22.12.0", "@types/react": "18.3.18", "@types/systemjs": "6.15.1", "jest": "^29.6.4", diff --git a/packages/grafana-o11y-ds-frontend/src/NodeGraph/NodeGraphSettings.tsx b/packages/grafana-o11y-ds-frontend/src/NodeGraph/NodeGraphSettings.tsx index 8b6f3c5f4b6..fd9ba9cd6c9 100644 --- a/packages/grafana-o11y-ds-frontend/src/NodeGraph/NodeGraphSettings.tsx +++ b/packages/grafana-o11y-ds-frontend/src/NodeGraph/NodeGraphSettings.tsx @@ -7,7 +7,7 @@ import { GrafanaTheme2, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; -import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui'; import { InlineField, InlineFieldRow, InlineSwitch, useStyles2 } from '@grafana/ui'; export interface NodeGraphOptions { diff --git a/packages/grafana-o11y-ds-frontend/src/SpanBar/SpanBarSettings.tsx b/packages/grafana-o11y-ds-frontend/src/SpanBar/SpanBarSettings.tsx index a612dee380d..11710cef528 100644 --- a/packages/grafana-o11y-ds-frontend/src/SpanBar/SpanBarSettings.tsx +++ b/packages/grafana-o11y-ds-frontend/src/SpanBar/SpanBarSettings.tsx @@ -7,7 +7,7 @@ import { toOption, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; -import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui'; import { InlineField, InlineFieldRow, Input, Select, useStyles2 } from '@grafana/ui'; export interface SpanBarOptions { diff --git a/packages/grafana-o11y-ds-frontend/src/TraceToLogs/TraceToLogsSettings.tsx b/packages/grafana-o11y-ds-frontend/src/TraceToLogs/TraceToLogsSettings.tsx index d94e09b0411..b5bca15f317 100644 --- a/packages/grafana-o11y-ds-frontend/src/TraceToLogs/TraceToLogsSettings.tsx +++ b/packages/grafana-o11y-ds-frontend/src/TraceToLogs/TraceToLogsSettings.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo } from 'react'; import * as React from 'react'; import { DataSourceJsonData, DataSourceInstanceSettings, DataSourcePluginOptionsEditorProps } from '@grafana/data'; -import { ConfigDescriptionLink, ConfigSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSection } from '@grafana/plugin-ui'; import { DataSourcePicker } from '@grafana/runtime'; import { InlineField, InlineFieldRow, Input, InlineSwitch } from '@grafana/ui'; diff --git a/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/TraceToMetricsSettings.tsx b/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/TraceToMetricsSettings.tsx index 98cff44f304..966c1703b54 100644 --- a/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/TraceToMetricsSettings.tsx +++ b/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/TraceToMetricsSettings.tsx @@ -7,7 +7,7 @@ import { GrafanaTheme2, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; -import { ConfigDescriptionLink, ConfigSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSection } from '@grafana/plugin-ui'; import { DataSourcePicker } from '@grafana/runtime'; import { Button, InlineField, InlineFieldRow, Input, useStyles2 } from '@grafana/ui'; diff --git a/packages/grafana-o11y-ds-frontend/src/TraceToProfiles/TraceToProfilesSettings.tsx b/packages/grafana-o11y-ds-frontend/src/TraceToProfiles/TraceToProfilesSettings.tsx index 9b2d5ca31cc..cb5af242966 100644 --- a/packages/grafana-o11y-ds-frontend/src/TraceToProfiles/TraceToProfilesSettings.tsx +++ b/packages/grafana-o11y-ds-frontend/src/TraceToProfiles/TraceToProfilesSettings.tsx @@ -9,7 +9,7 @@ import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; -import { ConfigDescriptionLink, ConfigSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSection } from '@grafana/plugin-ui'; import { DataSourcePicker, DataSourceWithBackend, getDataSourceSrv } from '@grafana/runtime'; import { InlineField, InlineFieldRow, Input, InlineSwitch } from '@grafana/ui'; diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 44a22efae28..695108bb59f 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -61,7 +61,7 @@ "marked": "15.0.6", "marked-mangle": "1.1.10", "moment": "2.30.1", - "moment-timezone": "0.5.46", + "moment-timezone": "0.5.47", "monaco-promql": "1.7.4", "pluralize": "8.0.0", "prismjs": "1.29.0", @@ -92,8 +92,8 @@ "@types/eslint": "9.6.1", "@types/jest": "29.5.14", "@types/jquery": "3.5.32", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/pluralize": "^0.0.33", "@types/prismjs": "1.26.5", "@types/react": "18.3.18", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 6df8bf7b619..56876aab254 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -58,7 +58,7 @@ "@types/angular": "1.8.9", "@types/history": "4.7.11", "@types/jest": "29.5.14", - "@types/lodash": "4.17.14", + "@types/lodash": "4.17.15", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "@types/systemjs": "6.15.1", diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index f8b087b1bc6..6b29cdc0dee 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -185,6 +185,7 @@ export class GrafanaBootConfig implements GrafanaConfig { rudderstackConfigUrl: undefined; rudderstackIntegrationsUrl: undefined; analyticsConsoleReporting = false; + dashboardPerformanceMetrics: string[] = []; sqlConnectionLimits = { maxOpenConns: 100, maxIdleConns: 100, diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 0c64456428b..a28fab8050a 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -9,7 +9,7 @@ export * from './analytics/types'; export { loadPluginCss, type PluginCssOptions, setPluginImportUtils, getPluginImportUtils } from './utils/plugin'; export { reportMetaAnalytics, reportInteraction, reportPageview, reportExperimentView } from './analytics/utils'; export { featureEnabled } from './utils/licensing'; -export { logInfo, logDebug, logWarning, logError, createMonitoringLogger } from './utils/logging'; +export { logInfo, logDebug, logWarning, logError, createMonitoringLogger, logMeasurement } from './utils/logging'; export { DataSourceWithBackend, HealthCheckError, diff --git a/packages/grafana-runtime/src/utils/logging.ts b/packages/grafana-runtime/src/utils/logging.ts index d7c94da53d8..98eb7241361 100644 --- a/packages/grafana-runtime/src/utils/logging.ts +++ b/packages/grafana-runtime/src/utils/logging.ts @@ -66,11 +66,13 @@ export function logError(err: Error, contexts?: LogContext) { export type MeasurementValues = Record; export function logMeasurement(type: string, values: MeasurementValues, context?: LogContext) { if (config.grafanaJavascriptAgent.enabled) { - faro.api.pushMeasurement({ - type, - values, - context, - }); + faro.api.pushMeasurement( + { + type, + values, + }, + { context: context } + ); } } diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json index 509a4b9832f..6bafb8a31ed 100644 --- a/packages/grafana-sql/package.json +++ b/packages/grafana-sql/package.json @@ -17,7 +17,7 @@ "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", "@grafana/e2e-selectors": "11.5.0-pre", - "@grafana/experimental": "2.1.6", + "@grafana/plugin-ui": "0.9.6", "@grafana/runtime": "11.5.0-pre", "@grafana/ui": "11.5.0-pre", "@react-awesome-query-builder/ui": "6.6.4", @@ -40,8 +40,8 @@ "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", "@types/jest": "^29.5.4", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "@types/react-virtualized-auto-sizer": "1.0.4", diff --git a/packages/grafana-sql/src/components/QueryEditor.tsx b/packages/grafana-sql/src/components/QueryEditor.tsx index 487c4f2c1c9..fa7bc199209 100644 --- a/packages/grafana-sql/src/components/QueryEditor.tsx +++ b/packages/grafana-sql/src/components/QueryEditor.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useAsync } from 'react-use'; import { QueryEditorProps } from '@grafana/data'; -import { EditorMode } from '@grafana/experimental'; +import { EditorMode } from '@grafana/plugin-ui'; import { Space } from '@grafana/ui'; import { SqlDatasource } from '../datasource/SqlDatasource'; diff --git a/packages/grafana-sql/src/components/QueryHeader.tsx b/packages/grafana-sql/src/components/QueryHeader.tsx index f1bede6cb4f..d7f7412d453 100644 --- a/packages/grafana-sql/src/components/QueryHeader.tsx +++ b/packages/grafana-sql/src/components/QueryHeader.tsx @@ -3,7 +3,7 @@ import { useCopyToClipboard } from 'react-use'; import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { EditorField, EditorHeader, EditorMode, EditorRow, FlexItem, InlineSelect } from '@grafana/experimental'; +import { EditorField, EditorHeader, EditorMode, EditorRow, FlexItem, InlineSelect } from '@grafana/plugin-ui'; import { reportInteraction } from '@grafana/runtime'; import { Button, InlineSwitch, RadioButtonGroup, Tooltip, Space } from '@grafana/ui'; diff --git a/packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx b/packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx index 17218d5188a..50bc7e82ae7 100644 --- a/packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx +++ b/packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx @@ -1,7 +1,7 @@ import { DataSourceSettings } from '@grafana/data'; -import { ConfigSubSection, Stack } from '@grafana/experimental'; +import { ConfigSubSection } from '@grafana/plugin-ui'; import { config } from '@grafana/runtime'; -import { Field, Icon, InlineLabel, Label, Switch, Tooltip } from '@grafana/ui'; +import { Field, Icon, InlineLabel, Label, Stack, Switch, Tooltip } from '@grafana/ui'; import { SQLConnectionLimits, SQLOptions } from '../../types'; diff --git a/packages/grafana-sql/src/components/query-editor-raw/QueryEditorRaw.tsx b/packages/grafana-sql/src/components/query-editor-raw/QueryEditorRaw.tsx index 2f9f7bd6eeb..ed79057cd55 100644 --- a/packages/grafana-sql/src/components/query-editor-raw/QueryEditorRaw.tsx +++ b/packages/grafana-sql/src/components/query-editor-raw/QueryEditorRaw.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import * as React from 'react'; -import { LanguageDefinition, SQLEditor } from '@grafana/experimental'; +import { LanguageDefinition, SQLEditor } from '@grafana/plugin-ui'; import { SQLQuery } from '../../types'; diff --git a/packages/grafana-sql/src/components/visual-query-builder/GroupByRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/GroupByRow.tsx index 33460b4e169..8f6b463c8ee 100644 --- a/packages/grafana-sql/src/components/visual-query-builder/GroupByRow.tsx +++ b/packages/grafana-sql/src/components/visual-query-builder/GroupByRow.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { SelectableValue, toOption } from '@grafana/data'; -import { AccessoryButton, EditorList, InputGroup } from '@grafana/experimental'; +import { AccessoryButton, EditorList, InputGroup } from '@grafana/plugin-ui'; import { Select } from '@grafana/ui'; import { QueryEditorGroupByExpression } from '../../expressions'; diff --git a/packages/grafana-sql/src/components/visual-query-builder/OrderByRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/OrderByRow.tsx index 3c2c274eeab..8e7286153a9 100644 --- a/packages/grafana-sql/src/components/visual-query-builder/OrderByRow.tsx +++ b/packages/grafana-sql/src/components/visual-query-builder/OrderByRow.tsx @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import * as React from 'react'; import { SelectableValue, toOption } from '@grafana/data'; -import { EditorField, InputGroup } from '@grafana/experimental'; +import { EditorField, InputGroup } from '@grafana/plugin-ui'; import { Input, RadioButtonGroup, Select, Space } from '@grafana/ui'; import { SQLExpression } from '../../types'; diff --git a/packages/grafana-sql/src/components/visual-query-builder/SelectColumn.tsx b/packages/grafana-sql/src/components/visual-query-builder/SelectColumn.tsx index 774ef7501f8..cea8b01e72a 100644 --- a/packages/grafana-sql/src/components/visual-query-builder/SelectColumn.tsx +++ b/packages/grafana-sql/src/components/visual-query-builder/SelectColumn.tsx @@ -2,7 +2,7 @@ import { useId } from 'react'; import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { EditorField } from '@grafana/experimental'; +import { EditorField } from '@grafana/plugin-ui'; import { Select } from '@grafana/ui'; interface Props { diff --git a/packages/grafana-sql/src/components/visual-query-builder/SelectFunctionParameters.tsx b/packages/grafana-sql/src/components/visual-query-builder/SelectFunctionParameters.tsx index 3d5c2203447..16241e3aac4 100644 --- a/packages/grafana-sql/src/components/visual-query-builder/SelectFunctionParameters.tsx +++ b/packages/grafana-sql/src/components/visual-query-builder/SelectFunctionParameters.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useId, useState } from 'react'; import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { EditorField } from '@grafana/experimental'; +import { EditorField } from '@grafana/plugin-ui'; import { InlineLabel, Input, Select, Stack, useStyles2 } from '@grafana/ui'; import { QueryEditorExpressionType } from '../../expressions'; diff --git a/packages/grafana-sql/src/components/visual-query-builder/SelectRow.test.tsx b/packages/grafana-sql/src/components/visual-query-builder/SelectRow.test.tsx index a54e1ef72ab..3b31add19ad 100644 --- a/packages/grafana-sql/src/components/visual-query-builder/SelectRow.test.tsx +++ b/packages/grafana-sql/src/components/visual-query-builder/SelectRow.test.tsx @@ -12,6 +12,7 @@ import { SelectRow } from './SelectRow'; // Mock featureToggle sqlQuerybuilderFunctionParameters jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), config: { featureToggles: { sqlQuerybuilderFunctionParameters: true, diff --git a/packages/grafana-sql/src/components/visual-query-builder/SelectRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/SelectRow.tsx index 302977d88cd..a553ece4a6c 100644 --- a/packages/grafana-sql/src/components/visual-query-builder/SelectRow.tsx +++ b/packages/grafana-sql/src/components/visual-query-builder/SelectRow.tsx @@ -4,7 +4,7 @@ import { useCallback } from 'react'; import { SelectableValue, toOption } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { EditorField } from '@grafana/experimental'; +import { EditorField } from '@grafana/plugin-ui'; import { config } from '@grafana/runtime'; import { Button, Select, Stack, useStyles2 } from '@grafana/ui'; diff --git a/packages/grafana-sql/src/components/visual-query-builder/VisualEditor.tsx b/packages/grafana-sql/src/components/visual-query-builder/VisualEditor.tsx index ca5e37fd41d..3a267301163 100644 --- a/packages/grafana-sql/src/components/visual-query-builder/VisualEditor.tsx +++ b/packages/grafana-sql/src/components/visual-query-builder/VisualEditor.tsx @@ -1,6 +1,6 @@ import { useAsync } from 'react-use'; -import { EditorRows, EditorRow, EditorField } from '@grafana/experimental'; +import { EditorRows, EditorRow, EditorField } from '@grafana/plugin-ui'; import { DB, QueryEditorProps, QueryRowFilter } from '../../types'; import { QueryToolbox } from '../query-editor-raw/QueryToolbox'; diff --git a/packages/grafana-sql/src/datasource/SqlDatasource.ts b/packages/grafana-sql/src/datasource/SqlDatasource.ts index 121d30a4731..ca4675034c7 100644 --- a/packages/grafana-sql/src/datasource/SqlDatasource.ts +++ b/packages/grafana-sql/src/datasource/SqlDatasource.ts @@ -17,7 +17,7 @@ import { VariableWithMultiSupport, TimeRange, } from '@grafana/data'; -import { EditorMode } from '@grafana/experimental'; +import { EditorMode } from '@grafana/plugin-ui'; import { BackendDataSourceResponse, DataSourceWithBackend, diff --git a/packages/grafana-sql/src/defaults.ts b/packages/grafana-sql/src/defaults.ts index 4532bdcc3a6..06930dcc8cc 100644 --- a/packages/grafana-sql/src/defaults.ts +++ b/packages/grafana-sql/src/defaults.ts @@ -1,4 +1,4 @@ -import { EditorMode } from '@grafana/experimental'; +import { EditorMode } from '@grafana/plugin-ui'; import { QueryFormat, SQLQuery } from './types'; import { createFunctionField, setGroupByField } from './utils/sql.utils'; diff --git a/packages/grafana-sql/src/types.ts b/packages/grafana-sql/src/types.ts index dd2af526b81..00c1a6e0456 100644 --- a/packages/grafana-sql/src/types.ts +++ b/packages/grafana-sql/src/types.ts @@ -9,7 +9,7 @@ import { TimeRange, toOption as toOptionFromData, } from '@grafana/data'; -import { CompletionItemKind, EditorMode, LanguageDefinition } from '@grafana/experimental'; +import { CompletionItemKind, EditorMode, LanguageDefinition } from '@grafana/plugin-ui'; import { QueryWithDefaults } from './defaults'; import { diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 548c93ee375..aa162a98040 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -64,7 +64,7 @@ "@react-aria/utils": "3.27.0", "@tanstack/react-virtual": "^3.5.1", "@types/jquery": "3.5.32", - "@types/lodash": "4.17.14", + "@types/lodash": "4.17.15", "@types/react-table": "7.7.20", "calculate-size": "1.1.1", "classnames": "2.5.1", @@ -145,7 +145,7 @@ "@types/is-hotkey": "0.1.10", "@types/jest": "29.5.14", "@types/mock-raf": "1.0.6", - "@types/node": "22.10.10", + "@types/node": "22.12.0", "@types/prismjs": "1.26.5", "@types/react": "18.3.18", "@types/react-color": "3.0.13", diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx index 9d3bc5fbfb8..5575d21df0e 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx @@ -162,15 +162,15 @@ export const AsyncOptionsWithLabels: Story = { return ( { - onChangeAction(val); - setArgs({ value: val }); + onChange={(value: ComboboxOption | null) => { + onChangeAction(value); + setArgs({ value }); }} /> @@ -205,7 +205,7 @@ export const AsyncOptionsWithOnlyValues: Story = { {...dynamicArgs} onChange={(value: ComboboxOption | null) => { onChangeAction(value); - setArgs({ value: value }); + setArgs({ value }); }} /> diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx index 0dc4f2e9128..36c368291ee 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx @@ -2,8 +2,10 @@ import { action } from '@storybook/addon-actions'; import { useArgs, useEffect, useState } from '@storybook/preview-api'; import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import { Field } from '../Forms/Field'; + import { MultiCombobox } from './MultiCombobox'; -import { generateOptions } from './storyUtils'; +import { generateOptions, fakeSearchAPI } from './storyUtils'; import { ComboboxOption } from './types'; const meta: Meta = { @@ -11,6 +13,9 @@ const meta: Meta = { component: MultiCombobox, }; +const loadOptionsAction = action('options called'); +const onChangeAction = action('onChange called'); + const commonArgs = { options: [ { label: 'wasd - 1', value: 'option1' }, @@ -35,6 +40,24 @@ export const Basic: Story = { render: (args) => { const [{ value }, setArgs] = useArgs(); + return ( + { + onChangeAction(val); + setArgs({ value: val }); + }} + /> + ); + }, +}; + +export const AutoSize: Story = { + args: { ...commonArgs, width: 'auto', minWidth: 20 }, + render: (args) => { + const [{ value }, setArgs] = useArgs(); + return ( = ({ numberOfOptions = 1e4, ...args }) => { - const [value, setValue] = useState([]); + const [dynamicArgs, setArgs] = useArgs(); + const [options, setOptions] = useState([]); - const [isLoading, setIsLoading] = useState(true); useEffect(() => { - setTimeout(() => { - generateOptions(numberOfOptions).then((options) => { - setIsLoading(false); - setOptions(options); - setValue([options[5].value]); - }); + setTimeout(async () => { + const options = await generateOptions(numberOfOptions); + setOptions(options); }, 1000); }, [numberOfOptions]); @@ -67,12 +87,11 @@ const ManyOptionsStory: StoryFn = ({ numberOfOptions = 1e4, ... return ( { - setValue(opts || []); - action('onChange')(opts); + setArgs({ value: opts }); + onChangeAction(opts); }} /> ); @@ -86,3 +105,71 @@ export const ManyOptions: StoryObj = { }, render: ManyOptionsStory, }; + +function loadOptionsWithLabels(inputValue: string) { + loadOptionsAction(inputValue); + return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`); +} + +export const AsyncOptionsWithLabels: Story = { + name: 'Async - options returns labels', + args: { + options: loadOptionsWithLabels, + value: [{ label: 'Option 69', value: '69' }], + placeholder: 'Select an option', + }, + render: (args) => { + const [dynamicArgs, setArgs] = useArgs(); + + return ( + + { + onChangeAction(val); + setArgs({ value: val }); + }} + /> + + ); + }, +}; + +function loadOptionsOnlyValues(inputValue: string) { + loadOptionsAction(inputValue); + return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`).then((options) => + options.map((opt) => ({ value: opt.label! })) + ); +} + +export const AsyncOptionsWithOnlyValues: Story = { + name: 'Async - options returns only values', + args: { + options: loadOptionsOnlyValues, + value: [{ value: 'Option 69' }], + placeholder: 'Select an option', + }, + render: (args) => { + const [dynamicArgs, setArgs] = useArgs(); + + return ( + + { + onChangeAction(val); + setArgs({ value: val }); + }} + /> + + ); + }, +}; diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx index 70be3c4148d..66444644ca4 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx @@ -1,8 +1,9 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent, { UserEvent } from '@testing-library/user-event'; import React from 'react'; import { MultiCombobox, MultiComboboxProps } from './MultiCombobox'; +import { ComboboxOption } from './types'; describe('MultiCombobox', () => { beforeAll(() => { @@ -91,15 +92,18 @@ describe('MultiCombobox', () => { await user.click(input); await user.click(await screen.findByRole('option', { name: 'A' })); - //Second option + // Second option await user.click(screen.getByRole('option', { name: 'C' })); - //Deselect + // Deselect await user.click(screen.getByRole('option', { name: 'A' })); - expect(onChange).toHaveBeenNthCalledWith(1, [first]); - expect(onChange).toHaveBeenNthCalledWith(2, [first, third]); - expect(onChange).toHaveBeenNthCalledWith(3, [third]); + expect(onChange).toHaveBeenNthCalledWith(1, [{ label: 'A', value: first }]); + expect(onChange).toHaveBeenNthCalledWith(2, [ + { label: 'A', value: first }, + { label: 'C', value: third }, + ]); + expect(onChange).toHaveBeenNthCalledWith(3, [{ label: 'C', value: third }]); }); it('should be able to render a value that is not in the options', async () => { @@ -138,7 +142,11 @@ describe('MultiCombobox', () => { await user.click(input); await user.click(await screen.findByText('All')); - expect(onChange).toHaveBeenCalledWith(['a', 'b', 'c']); + expect(onChange).toHaveBeenCalledWith([ + { label: 'A', value: 'a' }, + { label: 'B', value: 'b' }, + { label: 'C', value: 'c' }, + ]); }); it('should deselect all option', async () => { @@ -157,4 +165,138 @@ describe('MultiCombobox', () => { expect(onChange).toHaveBeenCalledWith([]); }); }); + + describe('async', () => { + const onChangeHandler = jest.fn(); + let user: ReturnType; + + beforeAll(() => { + user = userEvent.setup({ delay: null }); + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + onChangeHandler.mockReset(); + }); + + // Assume that most apis only return with the value + const simpleAsyncOptions = [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }]; + + it('should allow async options', async () => { + const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions)); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + // Debounce + await act(async () => jest.advanceTimersByTime(200)); + + expect(asyncOptions).toHaveBeenCalled(); + }); + + it('should allow async options and select value', async () => { + const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions)); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + const item = await screen.findByRole('option', { name: 'Option 3' }); + await user.click(item); + + expect(onChangeHandler).toHaveBeenCalledWith([simpleAsyncOptions[2]]); + }); + + it('should retain values not returned by the async function', async () => { + const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions)); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + const item = await screen.findByRole('option', { name: 'Option 3' }); + await user.click(item); + + expect(onChangeHandler).toHaveBeenCalledWith([{ value: 'Option 69' }, { value: 'Option 3' }]); + }); + + it('should ignore late responses', async () => { + const asyncOptions = jest.fn(async (searchTerm: string) => { + if (searchTerm === 'a') { + return promiseResolvesWith([{ value: 'first' }], 1500); + } else if (searchTerm === 'ab') { + return promiseResolvesWith([{ value: 'second' }], 500); + } else if (searchTerm === 'abc') { + return promiseResolvesWith([{ value: 'third' }], 800); + } + + return Promise.resolve([]); + }); + + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await user.keyboard('a'); + act(() => jest.advanceTimersByTime(200)); // Skip debounce + + await user.keyboard('b'); + act(() => jest.advanceTimersByTime(200)); // Skip debounce + + await user.keyboard('c'); + act(() => jest.advanceTimersByTime(500)); // Resolve the second request, should be ignored + + expect(screen.queryByRole('option', { name: 'first' })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'second' })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'third' })).not.toBeInTheDocument(); + + jest.advanceTimersByTime(800); // Resolve the third request, should be shown + expect(screen.queryByRole('option', { name: 'first' })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'second' })).not.toBeInTheDocument(); + expect(await screen.findByRole('option', { name: 'third' })).toBeInTheDocument(); + + jest.advanceTimersByTime(1500); // Resolve the first request, should be ignored + expect(screen.queryByRole('option', { name: 'first' })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'second' })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'third' })).toBeInTheDocument(); + + jest.clearAllTimers(); + }); + + it('should debounce requests', async () => { + const asyncOptions = jest.fn(async () => { + return promiseResolvesWith([{ value: 'Option 3' }], 1); + }); + + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await user.keyboard('a'); + act(() => jest.advanceTimersByTime(10)); + + await user.keyboard('b'); + act(() => jest.advanceTimersByTime(10)); + + await user.keyboard('c'); + act(() => jest.advanceTimersByTime(200)); + + const item = await screen.findByRole('option', { name: 'Option 3' }); + expect(item).toBeInTheDocument(); + + expect(asyncOptions).toHaveBeenCalledTimes(1); + expect(asyncOptions).toHaveBeenCalledWith('abc'); + }); + }); }); + +function promiseResolvesWith(value: ComboboxOption[], timeout = 0) { + return new Promise((resolve) => setTimeout(() => resolve(value), timeout)); +} diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index 0cede71af9e..104f638de60 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -10,41 +10,33 @@ import { Box } from '../Layout/Box/Box'; import { Stack } from '../Layout/Stack/Stack'; import { Portal } from '../Portal/Portal'; import { ScrollContainer } from '../ScrollContainer/ScrollContainer'; -import { Spinner } from '../Spinner/Spinner'; import { Text } from '../Text/Text'; import { Tooltip } from '../Tooltip'; import { ComboboxBaseProps, AutoSizeConditionals, VIRTUAL_OVERSCAN_ITEMS } from './Combobox'; import { NotFoundError } from './MessageRows'; import { OptionListItem } from './OptionListItem'; +import { SuffixIcon } from './SuffixIcon'; import { ValuePill } from './ValuePill'; -import { itemFilter, itemToString } from './filter'; +import { itemToString } from './filter'; import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles'; import { getMultiComboboxStyles } from './getMultiComboboxStyles'; import { ALL_OPTION_VALUE, ComboboxOption } from './types'; import { useComboboxFloat } from './useComboboxFloat'; import { MAX_SHOWN_ITEMS, useMeasureMulti } from './useMeasureMulti'; +import { useMultiInputAutoSize } from './useMultiInputAutoSize'; +import { useOptions } from './useOptions'; interface MultiComboboxBaseProps extends Omit, 'value' | 'onChange'> { value?: T[] | Array>; - onChange: (items?: T[]) => void; + onChange: (option: Array>) => void; enableAllOption?: boolean; } export type MultiComboboxProps = MultiComboboxBaseProps & AutoSizeConditionals; export const MultiCombobox = (props: MultiComboboxProps) => { - const { options, placeholder, onChange, value, width, enableAllOption, invalid, loading, disabled } = props; - const isAsync = typeof options === 'function'; - - const selectedItems = useMemo(() => { - if (!value || isAsync) { - //TODO handle async - return []; - } - - return getSelectedItemsFromValue(value, options); - }, [value, options, isAsync]); + const { placeholder, onChange, value, width, enableAllOption, invalid, disabled, minWidth, maxWidth } = props; const styles = useStyles2(getComboboxStyles); const [inputValue, setInputValue] = useState(''); @@ -60,19 +52,22 @@ export const MultiCombobox = (props: MultiComboboxPro } as ComboboxOption; }, [inputValue]); - const baseItems = useMemo(() => { - return isAsync ? [] : enableAllOption ? [allOptionItem, ...options] : options; - }, [options, enableAllOption, allOptionItem, isAsync]); + // Handle async options and the 'All' option + const { options: baseOptions, updateOptions, asyncLoading } = useOptions(props.options); + const options = useMemo(() => { + // Only add the 'All' option if there's more than 1 option + const addAllOption = enableAllOption && baseOptions.length > 1; + return addAllOption ? [allOptionItem, ...baseOptions] : baseOptions; + }, [baseOptions, enableAllOption, allOptionItem]); + const loading = props.loading || asyncLoading; - const items = useMemo(() => { - const newItems = baseItems.filter(itemFilter(inputValue)); - - if (enableAllOption && newItems.length === 1 && newItems[0] === allOptionItem) { + const selectedItems = useMemo(() => { + if (!value) { return []; } - return newItems; - }, [baseItems, inputValue, enableAllOption, allOptionItem]); + return getSelectedItemsFromValue(value, baseOptions); + }, [value, baseOptions]); const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti( selectedItems, @@ -85,51 +80,53 @@ export const MultiCombobox = (props: MultiComboboxPro [selectedItems] ); - const { getSelectedItemProps, getDropdownProps, removeSelectedItem } = useMultipleSelection({ - selectedItems, //initally selected items, - onStateChange: ({ type, selectedItems: newSelectedItems }) => { - switch (type) { - case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace: - case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete: - case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace: - case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem: - if (newSelectedItems) { - onChange(getComboboxOptionsValues(newSelectedItems)); - } - break; + const { getSelectedItemProps, getDropdownProps, setSelectedItems, addSelectedItem, removeSelectedItem } = + useMultipleSelection({ + selectedItems, // initally selected items, + onStateChange: ({ type, selectedItems: newSelectedItems }) => { + switch (type) { + case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace: + case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete: + case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace: + case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem: + case useMultipleSelection.stateChangeTypes.FunctionAddSelectedItem: + case useMultipleSelection.stateChangeTypes.FunctionSetSelectedItems: + // Unclear why newSelectedItems would be undefined, but this seems logical + onChange(newSelectedItems ?? []); + break; - default: - break; - } - }, - stateReducer: (state, actionAndChanges) => { - const { changes } = actionAndChanges; - return { - ...changes, + default: + break; + } + }, + stateReducer: (state, actionAndChanges) => { + const { changes } = actionAndChanges; + return { + ...changes, - /** - * TODO: Fix Hack! - * This prevents the menu from closing when the user unselects an item in the dropdown at the expense - * of breaking keyboard navigation. - * - * Downshift isn't really designed to keep selected items in the dropdown menu, so when you unselect an item - * in a multiselect, the stateReducer tries to move focus onto another item which causes the menu to be closed. - * This only seems to happen when you deselect the last item in the selectedItems list. - * - * Check out: - * - FunctionRemoveSelectedItem in the useMultipleSelection reducer https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/reducer.js#L75 - * - The activeIndex useEffect in useMultipleSelection https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/index.js#L68-L72 - * - * Forcing the activeIndex to -999 both prevents the useEffect that changes the focus from triggering (value never changes) - * and prevents the if statement in useMultipleSelection from focusing anything. - */ - activeIndex: -999, - }; - }, - }); + /** + * TODO: Fix Hack! + * This prevents the menu from closing when the user unselects an item in the dropdown at the expense + * of breaking keyboard navigation. + * + * Downshift isn't really designed to keep selected items in the dropdown menu, so when you unselect an item + * in a multiselect, the stateReducer tries to move focus onto another item which causes the menu to be closed. + * This only seems to happen when you deselect the last item in the selectedItems list. + * + * Check out: + * - FunctionRemoveSelectedItem in the useMultipleSelection reducer https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/reducer.js#L75 + * - The activeIndex useEffect in useMultipleSelection https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/index.js#L68-L72 + * + * Forcing the activeIndex to -999 both prevents the useEffect that changes the focus from triggering (value never changes) + * and prevents the if statement in useMultipleSelection from focusing anything. + */ + activeIndex: -999, + }; + }, + }); const { - //getToggleButtonProps, + getToggleButtonProps, //getLabelProps, isOpen, highlightedIndex, @@ -137,12 +134,25 @@ export const MultiCombobox = (props: MultiComboboxPro getInputProps, getItemProps, } = useCombobox({ - items, + items: options, itemToString, inputValue, selectedItem: null, stateReducer: (state, actionAndChanges) => { - const { changes, type } = actionAndChanges; + const { type } = actionAndChanges; + let { changes } = actionAndChanges; + const menuBeingOpened = state.isOpen === false && changes.isOpen === true; + + // Reset the input value when the menu is opened. If the menu is opened due to an input change + // then make sure we keep that. + // This will trigger onInputValueChange to load async options + if (menuBeingOpened && changes.inputValue === state.inputValue) { + changes = { + ...changes, + inputValue: '', + }; + } + switch (type) { case useCombobox.stateChangeTypes.InputKeyDownEnter: case useCombobox.stateChangeTypes.ItemClick: @@ -158,39 +168,50 @@ export const MultiCombobox = (props: MultiComboboxPro } }, + onIsOpenChange: ({ isOpen, inputValue }) => { + if (isOpen && inputValue === '') { + updateOptions(inputValue); + } + }, + onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => { switch (type) { case useCombobox.stateChangeTypes.InputKeyDownEnter: case useCombobox.stateChangeTypes.ItemClick: // Handle All functionality if (newSelectedItem?.value === ALL_OPTION_VALUE) { - const allFilteredSelected = selectedItems.length === items.length - 1; - let newSelectedItems = allFilteredSelected && inputValue === '' ? [] : baseItems.slice(1); + // TODO: fix bug where if the search filtered items list is the + // same length, but different, than the selected items (ask tobias) + const isAllFilteredSelected = selectedItems.length === options.length - 1; - if (!allFilteredSelected && inputValue !== '') { + // if every option is already selected, clear the selection. + // otherwise, select all the options (excluding the first ALL_OTION) + const realOptions = options.slice(1); + let newSelectedItems = isAllFilteredSelected && inputValue === '' ? [] : realOptions; + + if (!isAllFilteredSelected && inputValue !== '') { // Select all currently filtered items and deduplicate - newSelectedItems = [...new Set([...selectedItems, ...items.slice(1)])]; + newSelectedItems = [...new Set([...selectedItems, ...realOptions])]; } - if (allFilteredSelected && inputValue !== '') { + if (isAllFilteredSelected && inputValue !== '') { // Deselect all currently filtered items - const filteredSet = new Set(items.slice(1).map((item) => item.value)); + const filteredSet = new Set(realOptions.map((item) => item.value)); newSelectedItems = selectedItems.filter((item) => !filteredSet.has(item.value)); } - onChange(getComboboxOptionsValues(newSelectedItems)); - break; - } - if (newSelectedItem) { - if (!isOptionSelected(newSelectedItem)) { - onChange(getComboboxOptionsValues([...selectedItems, newSelectedItem])); - break; - } - removeSelectedItem(newSelectedItem); // onChange is handled by multiselect here + setSelectedItems(newSelectedItems); + } else if (newSelectedItem && isOptionSelected(newSelectedItem)) { + removeSelectedItem(newSelectedItem); + } else if (newSelectedItem) { + addSelectedItem(newSelectedItem); } + break; case useCombobox.stateChangeTypes.InputChange: setInputValue(newInputValue ?? ''); + updateOptions(newInputValue ?? ''); + break; default: break; @@ -198,14 +219,14 @@ export const MultiCombobox = (props: MultiComboboxPro }, }); - const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen); - const multiStyles = useStyles2(getMultiComboboxStyles, isOpen, invalid, disabled); + const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(options, isOpen); + const multiStyles = useStyles2(getMultiComboboxStyles, isOpen, invalid, disabled, width, minWidth, maxWidth); const virtualizerOptions = { - count: items.length, + count: options.length, getScrollElement: () => scrollRef.current, estimateSize: (index: number) => - 'description' in items[index] ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT, + 'description' in options[index] ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT, overscan: VIRTUAL_OVERSCAN_ITEMS, }; @@ -214,13 +235,10 @@ export const MultiCombobox = (props: MultiComboboxPro // Selected items that show up in the input field const visibleItems = isOpen ? selectedItems.slice(0, MAX_SHOWN_ITEMS) : selectedItems.slice(0, shownItems); + const { inputRef, inputWidth } = useMultiInputAutoSize(inputValue); return ( -
-
+
+
{visibleItems.map((item, index) => ( (props: MultiComboboxPro getDropdownProps({ disabled, preventKeyAction: isOpen, - placeholder: selectedItems.length > 0 ? undefined : placeholder, + placeholder, + ref: inputRef, + style: { width: inputWidth }, }) )} /> - {loading && ( -
- -
- )} + +
+ +
@@ -280,13 +299,16 @@ export const MultiCombobox = (props: MultiComboboxPro
    {rowVirtualizer.getVirtualItems().map((virtualRow) => { const index = virtualRow.index; - const item = items[index]; + const item = options[index]; const itemProps = getItemProps({ item, index }); const isSelected = isOptionSelected(item); const id = 'multicombobox-option-' + item.value.toString(); const isAll = item.value === ALL_OPTION_VALUE; + + // TODO: fix bug where if the search filtered items list is the + // same length, but different, than the selected items (ask tobias) const allItemsSelected = - items[0]?.value === ALL_OPTION_VALUE && selectedItems.length === items.length - 1; + options[0]?.value === ALL_OPTION_VALUE && selectedItems.length === options.length - 1; return (
  • (props: MultiComboboxPro label={ isAll ? (item.label ?? item.value.toString()) + - (isAll && inputValue !== '' ? ` (${items.length - 1})` : '') + (isAll && inputValue !== '' ? ` (${options.length - 1})` : '') : (item.label ?? item.value.toString()) } description={item?.description} @@ -321,7 +343,7 @@ export const MultiCombobox = (props: MultiComboboxPro ); })}
-
{items.length === 0 && }
+
{options.length === 0 && }
)}
@@ -364,7 +386,3 @@ function isComboboxOptions( ): value is Array> { return typeof value[0] === 'object'; } - -function getComboboxOptionsValues(optionArray: Array>) { - return optionArray.map((option) => option.value); -} diff --git a/packages/grafana-ui/src/components/Combobox/SuffixIcon.tsx b/packages/grafana-ui/src/components/Combobox/SuffixIcon.tsx new file mode 100644 index 00000000000..04fa29b699b --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/SuffixIcon.tsx @@ -0,0 +1,17 @@ +import { Icon } from '../Icon/Icon'; + +interface Props { + isLoading: boolean; + isOpen: boolean; +} + +export const SuffixIcon = ({ isLoading, isOpen }: Props) => { + const suffixIcon = isLoading + ? 'spinner' + : // If it's loading, show loading icon. Otherwise, icon indicating menu state + isOpen + ? 'search' + : 'angle-down'; + + return ; +}; diff --git a/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts index 4347acdb0e7..06bd2dd2a22 100644 --- a/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts +++ b/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts @@ -9,18 +9,33 @@ export const getMultiComboboxStyles = ( theme: GrafanaTheme2, isOpen: boolean, invalid?: boolean, - disabled?: boolean + disabled?: boolean, + width?: number | 'auto', + minWidth?: number, + maxWidth?: number ) => { const inputStyles = getInputStyles({ theme, invalid }); const focusStyles = getFocusStyles(theme); + const wrapperWidth = width && width !== 'auto' ? theme.spacing(width) : '100%'; + const wrapperMinWidth = minWidth ? theme.spacing(minWidth) : ''; + const wrapperMaxWidth = maxWidth ? theme.spacing(maxWidth) : ''; + return { + container: css({ + width: width === 'auto' ? 'auto' : wrapperWidth, + minWidth: wrapperMinWidth, + maxWidth: wrapperMaxWidth, + display: width === 'auto' ? 'inline-block' : 'block', + }), // wraps everything wrapper: cx( inputStyles.input, css({ display: 'flex', + width: '100%', gap: theme.spacing(0.5), padding: theme.spacing(0.5), + paddingRight: 28, // Account for suffix '&:focus-within': { ...focusStyles, }, @@ -31,7 +46,8 @@ export const getMultiComboboxStyles = ( outline: 'none', background: 'transparent', flexGrow: 1, - minWidth: '0', + maxWidth: '100%', + minWidth: 40, // This is a bit arbitrary, but is used to leave some space for clicking. This will override the minWidth property '&::placeholder': { color: theme.colors.text.disabled, }, diff --git a/packages/grafana-ui/src/components/Combobox/useMultiInputAutoSize.tsx b/packages/grafana-ui/src/components/Combobox/useMultiInputAutoSize.tsx new file mode 100644 index 00000000000..30ade93a632 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/useMultiInputAutoSize.tsx @@ -0,0 +1,33 @@ +import { useLayoutEffect, useRef, useState } from 'react'; + +import { measureText } from '../../utils'; + +export function useMultiInputAutoSize(inputValue: string) { + const inputRef = useRef(null); + const initialInputWidth = useRef(0); // Store initial width to prevent resizing on backspace + const [inputWidth, setInputWidth] = useState(''); + + useLayoutEffect(() => { + if (inputRef.current && inputValue == null && initialInputWidth.current === 0) { + initialInputWidth.current = inputRef?.current.getBoundingClientRect().width; + } + + if (!inputRef.current || inputValue == null) { + setInputWidth(''); + return; + } + + const fontSize = window.getComputedStyle(inputRef.current).fontSize; + const textWidth = measureText(inputRef.current.value || '', parseInt(fontSize, 10)).width; + + if (textWidth < initialInputWidth.current) { + // Let input fill all space before resizing + setInputWidth(''); + } else { + // Add pixels to prevent clipping + setInputWidth(`${textWidth + 5}px`); + } + }, [inputValue]); + + return { inputRef, inputWidth }; +} diff --git a/packages/grafana-ui/src/components/Combobox/useOptions.ts b/packages/grafana-ui/src/components/Combobox/useOptions.ts new file mode 100644 index 00000000000..504f3584e1d --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/useOptions.ts @@ -0,0 +1,82 @@ +import { debounce } from 'lodash'; +import { useState, useCallback, useMemo } from 'react'; + +import { itemFilter } from './filter'; +import { ComboboxOption } from './types'; +import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall'; + +type AsyncOptions = + | Array> + | ((inputValue: string) => Promise>>); + +const asyncNoop = () => Promise.resolve([]); + +/** + * Abstracts away sync/async options for MultiCombobox (and later Combobox). + * It also filters options based on the user's input. + * + * Returns: + * - options either filtered by user's input, or from async options fn + * - function to call when user types (to filter, or call async fn) + * - loading and error states + */ +export function useOptions(rawOptions: AsyncOptions) { + const isAsync = typeof rawOptions === 'function'; + + const loadOptions = useLatestAsyncCall(isAsync ? rawOptions : asyncNoop); + + const debouncedLoadOptions = useMemo( + () => + debounce((searchTerm: string) => { + return loadOptions(searchTerm) + .then((options) => { + setAsyncOptions(options); + setAsyncLoading(false); + setAsyncError(false); + }) + .catch((error) => { + if (!(error instanceof StaleResultError)) { + setAsyncError(true); + setAsyncLoading(false); + + if (error) { + console.error('Error loading async options for Combobox', error); + } + } + }); + }, 200), + [loadOptions] + ); + + const [asyncOptions, setAsyncOptions] = useState>>([]); + const [asyncLoading, setAsyncLoading] = useState(false); + const [asyncError, setAsyncError] = useState(false); + + // This hook keeps its own inputValue state (rather than accepting it as an arg) because it needs to be + // told it for async options loading anyway. + const [userTypedSearch, setUserTypedSearch] = useState(''); + + const updateOptions = useCallback( + (inputValue: string) => { + if (!isAsync) { + setUserTypedSearch(inputValue); + return; + } + + setAsyncLoading(true); + + debouncedLoadOptions(inputValue); + }, + [debouncedLoadOptions, isAsync] + ); + + const finalOptions = useMemo(() => { + if (isAsync) { + return asyncOptions; + } else { + return rawOptions.filter(itemFilter(userTypedSearch)); + } + }, [rawOptions, asyncOptions, isAsync, userTypedSearch]); + + return { options: finalOptions, updateOptions, asyncLoading, asyncError }; +} diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index cb0527a9961..b2e142d6a64 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx @@ -108,6 +108,8 @@ const maybeZoomAction = (e?: MouseEvent | null) => e != null && !e.ctrlKey && !e const getDataLinksFallback: GetDataLinksCallback = () => []; +const userAgentIsMobile = /Android|iPhone|iPad/i.test(navigator.userAgent); + /** * @alpha */ @@ -666,8 +668,9 @@ export const TooltipPlugin2 = ({ // if not viaSync, re-dispatch real event if (event != null) { - // we expect to re-dispatch mousemove, but on mobile we'll get mouseup or click - const isMobile = event.type !== 'mousemove'; + // we expect to re-dispatch mousemove, but may have a different event type, so create a mousemove event and fire that instead + // this doesn't work for every mobile device, so fall back to checking the useragent as well + const isMobile = event.type !== 'mousemove' || userAgentIsMobile; if (isMobile) { event = new MouseEvent('mousemove', { diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 486911d626c..5079d7d9877 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -24,6 +24,7 @@ import ( contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" + "github.com/grafana/grafana/pkg/services/dashboardversion/dashverimpl" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" @@ -828,21 +829,22 @@ func (hs *HTTPServer) GetDashboardVersions(c *contextmodel.ReqContext) response. } query := dashver.ListDashboardVersionsQuery{ - OrgID: c.SignedInUser.GetOrgID(), - DashboardID: dash.ID, - DashboardUID: dash.UID, - Limit: c.QueryInt("limit"), - Start: c.QueryInt("start"), + OrgID: c.SignedInUser.GetOrgID(), + DashboardID: dash.ID, + DashboardUID: dash.UID, + Limit: c.QueryInt("limit"), + Start: c.QueryInt("start"), + ContinueToken: c.Query("continueToken"), } - versions, err := hs.dashboardVersionService.List(c.Req.Context(), &query) + resp, err := hs.dashboardVersionService.List(c.Req.Context(), &query) if err != nil { return response.Error(http.StatusNotFound, fmt.Sprintf("No versions found for dashboardId %d", dash.ID), err) } - loginMem := make(map[int64]string, len(versions)) - res := make([]dashver.DashboardVersionMeta, 0, len(versions)) - for _, version := range versions { + loginMem := make(map[int64]string, len(resp.Versions)) + res := make([]dashver.DashboardVersionMeta, 0, len(resp.Versions)) + for _, version := range resp.Versions { msg := version.Message if version.RestoredFrom == version.Version { msg = "Initial save (created by migration)" @@ -883,7 +885,10 @@ func (hs *HTTPServer) GetDashboardVersions(c *contextmodel.ReqContext) response. }) } - return response.JSON(http.StatusOK, res) + return response.JSON(http.StatusOK, dashver.DashboardVersionResponseMeta{ + Versions: res, + ContinueToken: resp.ContinueToken, + }) } // swagger:route GET /dashboards/id/{DashboardID}/versions/{DashboardVersionID} dashboard_versions getDashboardVersionByID @@ -943,12 +948,15 @@ func (hs *HTTPServer) GetDashboardVersion(c *contextmodel.ReqContext) response.R return dashboardGuardianResponse(err) } - version, _ := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 32) + version, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) + if err != nil { + return response.Err(err) + } query := dashver.GetDashboardVersionQuery{ OrgID: c.SignedInUser.GetOrgID(), DashboardID: dash.ID, DashboardUID: dash.UID, - Version: int(version), + Version: version, } res, err := hs.dashboardVersionService.Get(c.Req.Context(), &query) @@ -1158,7 +1166,7 @@ func (hs *HTTPServer) RestoreDashboardVersion(c *contextmodel.ReqContext) respon saveCmd.Dashboard = version.Data saveCmd.Dashboard.Set("version", dash.Version) saveCmd.Dashboard.Set("uid", dash.UID) - saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version) + saveCmd.Message = dashverimpl.DashboardRestoreMessage(version.Version) // nolint:staticcheck saveCmd.FolderID = dash.FolderID metrics.MFolderIDsAPICount.WithLabelValues(metrics.RestoreDashboardVersion).Inc() diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 34c6c2c4dbe..a08deefb4bb 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -743,10 +743,10 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) { }).callGetDashboardVersions(sc) assert.Equal(t, http.StatusOK, sc.resp.Code) - var versions []dashver.DashboardVersionMeta + var versions dashver.DashboardVersionResponseMeta err := json.NewDecoder(sc.resp.Body).Decode(&versions) require.NoError(t, err) - for _, v := range versions { + for _, v := range versions.Versions { assert.Equal(t, "test-user", v.CreatedBy) } }, mockSQLStore) @@ -769,10 +769,10 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) { }).callGetDashboardVersions(sc) assert.Equal(t, http.StatusOK, sc.resp.Code) - var versions []dashver.DashboardVersionMeta + var versions dashver.DashboardVersionResponseMeta err := json.NewDecoder(sc.resp.Body).Decode(&versions) require.NoError(t, err) - for _, v := range versions { + for _, v := range versions.Versions { assert.Equal(t, anonString, v.CreatedBy) } }, mockSQLStore) @@ -795,10 +795,10 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) { }).callGetDashboardVersions(sc) assert.Equal(t, http.StatusOK, sc.resp.Code) - var versions []dashver.DashboardVersionMeta + var versions dashver.DashboardVersionResponseMeta err := json.NewDecoder(sc.resp.Body).Decode(&versions) require.NoError(t, err) - for _, v := range versions { + for _, v := range versions.Versions { assert.Equal(t, anonString, v.CreatedBy) } }, mockSQLStore) diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 9d8f96f8c7a..73c3d7cae6f 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -54,10 +54,10 @@ type CalculateDiffOptions struct { type CalculateDiffTarget struct { DashboardId int64 `json:"dashboardId"` - Version int `json:"version"` + Version int64 `json:"version"` UnsavedDashboard *simplejson.Json `json:"unsavedDashboard"` } type RestoreDashboardVersionCommand struct { - Version int `json:"version" binding:"Required"` + Version int64 `json:"version" binding:"Required"` } diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index d7094de9d90..6827525be04 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -190,6 +190,8 @@ type FrontendSettingsDTO struct { AnalyticsConsoleReporting bool `json:"analyticsConsoleReporting"` + DashboardPerformanceMetrics []string `json:"dashboardPerformanceMetrics"` + FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"` ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"` ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index f11114b299a..8ec544337b0 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -215,6 +215,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro RudderstackConfigUrl: hs.Cfg.RudderstackConfigURL, RudderstackIntegrationsUrl: hs.Cfg.RudderstackIntegrationsURL, AnalyticsConsoleReporting: hs.Cfg.FrontendAnalyticsConsoleReporting, + DashboardPerformanceMetrics: hs.Cfg.DashboardPerformanceMetrics, FeedbackLinksEnabled: hs.Cfg.FeedbackLinksEnabled, ApplicationInsightsConnectionString: hs.Cfg.ApplicationInsightsConnectionString, ApplicationInsightsEndpointUrl: hs.Cfg.ApplicationInsightsEndpointUrl, diff --git a/pkg/components/dashdiffs/compare.go b/pkg/components/dashdiffs/compare.go index e5c6e841901..7bb06a92ff2 100644 --- a/pkg/components/dashdiffs/compare.go +++ b/pkg/components/dashdiffs/compare.go @@ -34,7 +34,7 @@ type Options struct { type DiffTarget struct { DashboardId int64 - Version int + Version int64 UnsavedDashboard *simplejson.Json } diff --git a/pkg/registry/apis/dashboard/legacy/query_dashboards.sql b/pkg/registry/apis/dashboard/legacy/query_dashboards.sql index e5d532ffc87..c4901404f25 100644 --- a/pkg/registry/apis/dashboard/legacy/query_dashboards.sql +++ b/pkg/registry/apis/dashboard/legacy/query_dashboards.sql @@ -30,11 +30,11 @@ WHERE dashboard.is_folder = {{ .Arg .Query.GetFolders }} {{ if .Query.Version }} AND dashboard_version.version = {{ .Arg .Query.Version }} {{ else if .Query.LastID }} - AND dashboard_version.version < {{ .Arg .Query.LastID }} + AND dashboard_version.version <= {{ .Arg .Query.LastID }} {{ end }} ORDER BY - dashboard_version.created ASC, - dashboard_version.version ASC, + dashboard_version.created DESC, + dashboard_version.version DESC, dashboard.uid ASC {{ else }} {{ if .Query.UID }} diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 999586b13a1..f3b86f8b639 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -115,8 +115,9 @@ func (a *dashboardSqlAccess) getRows(ctx context.Context, sql *legacysql.LegacyD rows = nil } return &rowsWrapper{ - rows: rows, - a: a, + rows: rows, + a: a, + history: query.GetHistory, // This looks up rules from the permissions on a user canReadDashboard: func(scopes ...string) bool { return true // ??? @@ -128,8 +129,9 @@ func (a *dashboardSqlAccess) getRows(ctx context.Context, sql *legacysql.LegacyD var _ resource.ListIterator = (*rowsWrapper)(nil) type rowsWrapper struct { - a *dashboardSqlAccess - rows *sql.Rows + a *dashboardSqlAccess + rows *sql.Rows + history bool canReadDashboard func(scopes ...string) bool @@ -157,7 +159,7 @@ func (r *rowsWrapper) Next() bool { // breaks after first readable value for r.rows.Next() { - r.row, err = r.a.scanRow(r.rows) + r.row, err = r.a.scanRow(r.rows, r.history) if err != nil { r.err = err return false @@ -187,6 +189,11 @@ func (r *rowsWrapper) ContinueToken() string { return r.row.token.String() } +// ContinueTokenWithCurrentRV implements resource.ListIterator. +func (r *rowsWrapper) ContinueTokenWithCurrentRV() string { + return r.row.token.String() +} + // Error implements resource.ListIterator. func (r *rowsWrapper) Error() error { return r.err @@ -218,7 +225,7 @@ func (r *rowsWrapper) Value() []byte { return b } -func (a *dashboardSqlAccess) scanRow(rows *sql.Rows) (*dashboardRow, error) { +func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRow, error) { dash := &dashboard.Dashboard{ TypeMeta: dashboard.DashboardResourceInfo.TypeMeta(), ObjectMeta: metav1.ObjectMeta{Annotations: make(map[string]string)}, @@ -255,8 +262,12 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows) (*dashboardRow, error) { ) row.token = &continueToken{orgId: orgId, id: dashboard_id} + // when listing from the history table, we want to use the version as the ID to continue from + if history { + row.token.id = version + } if err == nil { - row.RV = getResourceVersion(dashboard_id, version) + row.RV = version dash.ResourceVersion = fmt.Sprintf("%d", row.RV) dash.Namespace = a.namespacer(orgId) dash.UID = gapiutil.CalculateClusterWideUID(dash) @@ -405,6 +416,7 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das } out, err := a.dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{ OrgID: orgId, + Message: meta.GetMessage(), PluginID: service.GetPluginIDFromMeta(meta), Dashboard: simplejson.NewFromAny(dash.Spec.UnstructuredContent()), FolderUID: meta.GetFolder(), diff --git a/pkg/registry/apis/dashboard/legacy/storage.go b/pkg/registry/apis/dashboard/legacy/storage.go index e5f51bf78f9..7064d1d9994 100644 --- a/pkg/registry/apis/dashboard/legacy/storage.go +++ b/pkg/registry/apis/dashboard/legacy/storage.go @@ -138,7 +138,7 @@ func (a *dashboardSqlAccess) ReadResource(ctx context.Context, req *resource.Rea } version := int64(0) if req.ResourceVersion > 0 { - version = getVersionFromRV(req.ResourceVersion) + version = req.ResourceVersion } dash, rv, err := a.GetDashboard(ctx, info.OrgID, req.Key.Name, version) diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid_at_version.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid_at_version.sql index d85d3bbd8c8..728e9a8e72b 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid_at_version.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid_at_version.sql @@ -19,6 +19,6 @@ WHERE dashboard.is_folder = FALSE AND dashboard.uid = 'UUU' AND dashboard_version.version = 3 ORDER BY - dashboard_version.created ASC, - dashboard_version.version ASC, + dashboard_version.created DESC, + dashboard_version.version DESC, dashboard.uid ASC diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid_at_version.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid_at_version.sql index 5fe3708643a..b149e393780 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid_at_version.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid_at_version.sql @@ -19,6 +19,6 @@ WHERE dashboard.is_folder = FALSE AND dashboard.uid = 'UUU' AND dashboard_version.version = 3 ORDER BY - dashboard_version.created ASC, - dashboard_version.version ASC, + dashboard_version.created DESC, + dashboard_version.version DESC, dashboard.uid ASC diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid_at_version.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid_at_version.sql index 5fe3708643a..b149e393780 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid_at_version.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid_at_version.sql @@ -19,6 +19,6 @@ WHERE dashboard.is_folder = FALSE AND dashboard.uid = 'UUU' AND dashboard_version.version = 3 ORDER BY - dashboard_version.created ASC, - dashboard_version.version ASC, + dashboard_version.created DESC, + dashboard_version.version DESC, dashboard.uid ASC diff --git a/pkg/registry/apis/dashboard/legacy/utils.go b/pkg/registry/apis/dashboard/legacy/utils.go deleted file mode 100644 index c61db27788a..00000000000 --- a/pkg/registry/apis/dashboard/legacy/utils.go +++ /dev/null @@ -1,9 +0,0 @@ -package legacy - -func getResourceVersion(id int64, version int64) int64 { - return version + (id * 10000000) -} - -func getVersionFromRV(rv int64) int64 { - return rv % 10000000 -} diff --git a/pkg/registry/apis/dashboard/legacy/utils_test.go b/pkg/registry/apis/dashboard/legacy/utils_test.go deleted file mode 100644 index a058dae612a..00000000000 --- a/pkg/registry/apis/dashboard/legacy/utils_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package legacy - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestVersionHacks(t *testing.T) { - rv := getResourceVersion(123, 456) - require.Equal(t, int64(1230000456), rv) - require.Equal(t, int64(456), getVersionFromRV(rv)) -} diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index d5e2f50901d..e3af9a28d9a 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -6,13 +6,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" - "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" - "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/util" ) @@ -41,52 +38,6 @@ func LegacyCreateCommandToUnstructured(cmd *folder.CreateFolderCommand) (*unstru return obj, nil } -func UnstructuredToLegacyFolder(item *unstructured.Unstructured) (*folder.Folder, error) { - meta, err := utils.MetaAccessor(item) - if err != nil { - return nil, err - } - - info, _ := authlib.ParseNamespace(meta.GetNamespace()) - if info.OrgID < 0 { - info.OrgID = 1 // This resolves all test cases that assume org 1 - } - - title, _, _ := unstructured.NestedString(item.Object, "spec", "title") - description, _, _ := unstructured.NestedString(item.Object, "spec", "description") - - uid := meta.GetName() - url := "" - if uid != folder.RootFolder.UID { - slug := slugify.Slugify(title) - url = dashboards.GetFolderURL(uid, slug) - } - - created := meta.GetCreationTimestamp().Time.UTC() - updated, _ := meta.GetUpdatedTimestamp() - if updated == nil { - updated = &created - } else { - tmp := updated.UTC() - updated = &tmp - } - - return &folder.Folder{ - UID: uid, - Title: title, - Description: description, - ID: meta.GetDeprecatedInternalID(), // nolint:staticcheck - ParentUID: meta.GetFolder(), - Version: int(meta.GetGeneration()), - Repository: meta.GetRepositoryName(), - - URL: url, - Created: created, - Updated: *updated, - OrgID: info.OrgID, - }, nil -} - func LegacyFolderToUnstructured(v *folder.Folder, namespacer request.NamespaceMapper) (*v0alpha1.Folder, error) { return convertToK8sResource(v, namespacer) } diff --git a/pkg/services/apiserver/client/client.go b/pkg/services/apiserver/client/client.go index 2e25ca07ee2..2f45e426bc3 100644 --- a/pkg/services/apiserver/client/client.go +++ b/pkg/services/apiserver/client/client.go @@ -2,7 +2,10 @@ package client import ( "context" + "errors" "fmt" + "strconv" + "strings" "time" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -16,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" k8sUser "k8s.io/apiserver/pkg/authentication/user" @@ -24,7 +28,7 @@ import ( type K8sHandler interface { GetNamespace(orgID int64) string - Get(ctx context.Context, name string, orgID int64, subresource ...string) (*unstructured.Unstructured, error) + Get(ctx context.Context, name string, orgID int64, options v1.GetOptions, subresource ...string) (*unstructured.Unstructured, error) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) Update(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) Delete(ctx context.Context, name string, orgID int64, options v1.DeleteOptions) error @@ -32,6 +36,7 @@ type K8sHandler interface { List(ctx context.Context, orgID int64, options v1.ListOptions) (*unstructured.UnstructuredList, error) Search(ctx context.Context, orgID int64, in *resource.ResourceSearchRequest) (*resource.ResourceSearchResponse, error) GetStats(ctx context.Context, orgID int64) (*resource.ResourceStatsResponse, error) + GetUserFromMeta(ctx context.Context, userMeta string) (*user.User, error) } var _ K8sHandler = (*k8sHandler)(nil) @@ -41,9 +46,10 @@ type k8sHandler struct { gvr schema.GroupVersionResource restConfigProvider apiserver.RestConfigProvider searcher resource.ResourceIndexClient + userService user.Service } -func NewK8sHandler(cfg *setting.Cfg, namespacer request.NamespaceMapper, gvr schema.GroupVersionResource, restConfigProvider apiserver.RestConfigProvider, searcher resource.ResourceIndexClient, dashStore dashboards.Store) K8sHandler { +func NewK8sHandler(cfg *setting.Cfg, namespacer request.NamespaceMapper, gvr schema.GroupVersionResource, restConfigProvider apiserver.RestConfigProvider, searcher resource.ResourceIndexClient, dashStore dashboards.Store, userSvc user.Service) K8sHandler { legacySearcher := legacysearcher.NewDashboardSearchClient(dashStore) searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, searcher, legacySearcher) return &k8sHandler{ @@ -51,6 +57,7 @@ func NewK8sHandler(cfg *setting.Cfg, namespacer request.NamespaceMapper, gvr sch gvr: gvr, restConfigProvider: restConfigProvider, searcher: searchClient, + userService: userSvc, } } @@ -58,7 +65,7 @@ func (h *k8sHandler) GetNamespace(orgID int64) string { return h.namespacer(orgID) } -func (h *k8sHandler) Get(ctx context.Context, name string, orgID int64, subresource ...string) (*unstructured.Unstructured, error) { +func (h *k8sHandler) Get(ctx context.Context, name string, orgID int64, options v1.GetOptions, subresource ...string) (*unstructured.Unstructured, error) { // create a new context - prevents issues when the request stems from the k8s api itself // otherwise the context goes through the handlers twice and causes issues newCtx, cancel, err := h.getK8sContext(ctx) @@ -73,7 +80,7 @@ func (h *k8sHandler) Get(ctx context.Context, name string, orgID int64, subresou return nil, nil } - return client.Get(newCtx, name, v1.GetOptions{}, subresource...) + return client.Get(newCtx, name, options, subresource...) } func (h *k8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*unstructured.Unstructured, error) { @@ -191,6 +198,28 @@ func (h *k8sHandler) GetStats(ctx context.Context, orgID int64) (*resource.Resou }) } +// GetUserFromMeta takes what meta accessor gives you from `GetCreatedBy` or `GetUpdatedBy` and returns the user +func (h *k8sHandler) GetUserFromMeta(ctx context.Context, userMeta string) (*user.User, error) { + parts := strings.Split(userMeta, ":") + if len(parts) < 2 { + return &user.User{}, nil + } + meta := parts[1] + + userId, err := strconv.ParseInt(meta, 10, 64) + var u *user.User + if err == nil { + u, err = h.userService.GetByID(ctx, &user.GetUserByIDQuery{ID: userId}) + } else { + u, err = h.userService.GetByUID(ctx, &user.GetUserByUIDQuery{UID: meta}) + } + + if err != nil && errors.Is(err, user.ErrUserNotFound) { + return &user.User{}, nil + } + return u, err +} + func (h *k8sHandler) getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) { cfg := h.restConfigProvider.GetRestConfig(ctx) if cfg == nil { diff --git a/pkg/services/apiserver/client/client_mock.go b/pkg/services/apiserver/client/client_mock.go index f3a4e347e2f..b5e2fc2eecb 100644 --- a/pkg/services/apiserver/client/client_mock.go +++ b/pkg/services/apiserver/client/client_mock.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/mock" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/storage/unified/resource" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -21,8 +22,8 @@ func (m *MockK8sHandler) GetNamespace(orgID int64) string { return args.String(0) } -func (m *MockK8sHandler) Get(ctx context.Context, name string, orgID int64, subresource ...string) (*unstructured.Unstructured, error) { - args := m.Called(ctx, name, orgID, subresource) +func (m *MockK8sHandler) Get(ctx context.Context, name string, orgID int64, options v1.GetOptions, subresource ...string) (*unstructured.Unstructured, error) { + args := m.Called(ctx, name, orgID, options, subresource) if args.Get(0) == nil { return nil, args.Error(1) } @@ -79,3 +80,11 @@ func (m *MockK8sHandler) GetStats(ctx context.Context, orgID int64) (*resource.R } return args.Get(0).(*resource.ResourceStatsResponse), args.Error(1) } + +func (m *MockK8sHandler) GetUserFromMeta(ctx context.Context, userMeta string) (*user.User, error) { + args := m.Called(ctx, userMeta) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*user.User), args.Error(1) +} diff --git a/pkg/services/apiserver/client/client_test.go b/pkg/services/apiserver/client/client_test.go new file mode 100644 index 00000000000..c97c5f0a31d --- /dev/null +++ b/pkg/services/apiserver/client/client_test.go @@ -0,0 +1,34 @@ +package client + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/usertest" + "github.com/stretchr/testify/require" +) + +func TestGetUserFromMeta(t *testing.T) { + userSvcTest := usertest.NewUserServiceFake() + userSvcTest.ExpectedUser = &user.User{ + ID: 1, + UID: "uid-value", + } + client := &k8sHandler{ + userService: userSvcTest, + } + t.Run("returns user with valid UID", func(t *testing.T) { + result, err := client.GetUserFromMeta(context.Background(), "user:uid-value") + require.NoError(t, err) + require.Equal(t, "uid-value", result.UID) + require.Equal(t, int64(1), result.ID) + }) + + t.Run("returns user when id is passed in", func(t *testing.T) { + result, err := client.GetUserFromMeta(context.Background(), "user:1") + require.NoError(t, err) + require.Equal(t, "uid-value", result.UID) + require.Equal(t, int64(1), result.ID) + }) +} diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 0c90a37542c..ad78d14952f 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -68,7 +68,6 @@ type DashboardServiceImpl struct { dashboardStore dashboards.Store folderStore folder.FolderStore folderService folder.Service - userService user.Service orgService org.Service features featuremgmt.FeatureToggles folderPermissions accesscontrol.FolderPermissionsService @@ -91,7 +90,7 @@ func ProvideDashboardServiceImpl( restConfigProvider apiserver.RestConfigProvider, userService user.Service, unified resource.ResourceClient, quotaService quota.Service, orgService org.Service, publicDashboardService publicdashboards.ServiceWrapper, ) (*DashboardServiceImpl, error) { - k8sHandler := client.NewK8sHandler(cfg, request.GetNamespaceMapper(cfg), v0alpha1.DashboardResourceInfo.GroupVersionResource(), restConfigProvider, unified, dashboardStore) + k8sHandler := client.NewK8sHandler(cfg, request.GetNamespaceMapper(cfg), v0alpha1.DashboardResourceInfo.GroupVersionResource(), restConfigProvider, unified, dashboardStore, userService) dashSvc := &DashboardServiceImpl{ cfg: cfg, @@ -103,7 +102,6 @@ func ProvideDashboardServiceImpl( folderStore: folderStore, folderService: folderSvc, orgService: orgService, - userService: userService, k8sclient: k8sHandler, metrics: newDashboardsMetrics(r), dashboardPermissionsReady: make(chan struct{}), @@ -1401,6 +1399,18 @@ func (dr *DashboardServiceImpl) GetDashboardTags(ctx context.Context, query *das } func (dr DashboardServiceImpl) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) (int64, error) { + if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { + dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ + OrgId: orgID, + FolderUIDs: folderUIDs, + }) + if err != nil { + return 0, err + } + + return int64(len(dashs)), nil + } + return dr.dashboardStore.CountDashboardsInFolders(ctx, &dashboards.CountDashboardsInFolderRequest{FolderUIDs: folderUIDs, OrgID: orgID}) } @@ -1477,7 +1487,7 @@ func (dr *DashboardServiceImpl) getDashboardThroughK8s(ctx context.Context, quer query.UID = result.UID } - out, err := dr.k8sclient.Get(ctx, query.UID, query.OrgID, subresource) + out, err := dr.k8sclient.Get(ctx, query.UID, query.OrgID, v1.GetOptions{}, subresource) if err != nil && !apierrors.IsNotFound(err) { return nil, err } else if err != nil || out == nil { @@ -1541,7 +1551,7 @@ func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd func (dr *DashboardServiceImpl) createOrUpdateDash(ctx context.Context, obj unstructured.Unstructured, orgID int64) (*dashboards.Dashboard, error) { var out *unstructured.Unstructured - current, err := dr.k8sclient.Get(ctx, obj.GetName(), orgID) + current, err := dr.k8sclient.Get(ctx, obj.GetName(), orgID, v1.GetOptions{}) if current == nil || err != nil { out, err = dr.k8sclient.Create(ctx, &obj, orgID) if err != nil { @@ -1739,7 +1749,7 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex for _, h := range searchResults.Hits { func(hit v0alpha1.DashboardHit) { g.Go(func() error { - out, err := dr.k8sclient.Get(ctx, hit.Name, query.OrgId) + out, err := dr.k8sclient.Get(ctx, hit.Name, query.OrgId, v1.GetOptions{}) if err != nil { return err } else if out == nil { @@ -1851,13 +1861,13 @@ func (dr *DashboardServiceImpl) UnstructuredToLegacyDashboard(ctx context.Contex out.PluginID = GetPluginIDFromMeta(obj) - creator, err := dr.getUserFromMeta(ctx, obj.GetCreatedBy()) + creator, err := dr.k8sclient.GetUserFromMeta(ctx, obj.GetCreatedBy()) if err != nil { return nil, err } out.CreatedBy = creator.ID - updater, err := dr.getUserFromMeta(ctx, obj.GetUpdatedBy()) + updater, err := dr.k8sclient.GetUserFromMeta(ctx, obj.GetUpdatedBy()) if err != nil { return nil, err } @@ -1894,25 +1904,6 @@ func (dr *DashboardServiceImpl) UnstructuredToLegacyDashboard(ctx context.Contex return &out, nil } -func (dr *DashboardServiceImpl) getUserFromMeta(ctx context.Context, userMeta string) (*user.User, error) { - if userMeta == "" || toUID(userMeta) == "" { - return &user.User{}, nil - } - usr, err := dr.getUser(ctx, toUID(userMeta)) - if err != nil && errors.Is(err, user.ErrUserNotFound) { - return &user.User{}, nil - } - return usr, err -} - -func (dr *DashboardServiceImpl) getUser(ctx context.Context, uid string) (*user.User, error) { - userId, err := strconv.ParseInt(uid, 10, 64) - if err == nil { - return dr.userService.GetByID(ctx, &user.GetUserByIDQuery{ID: userId}) - } - return dr.userService.GetByUID(ctx, &user.GetUserByUIDQuery{UID: uid}) -} - var pluginIDRepoName = "plugin" var fileProvisionedRepoPrefix = "file:" @@ -1998,11 +1989,3 @@ func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, names return finalObj, nil } - -func toUID(rawIdentifier string) string { - parts := strings.Split(rawIdentifier, ":") - if len(parts) < 2 { - return "" - } - return parts[1] -} diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 27653a41b63..d7adcd9ad9a 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -26,7 +26,6 @@ import ( "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -315,7 +314,8 @@ func TestGetDashboard(t *testing.T) { Version: 1, Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), } - k8sCliMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil).Once() + k8sCliMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil).Once() + k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) dashboard, err := service.GetDashboard(ctx, query) require.NoError(t, err) @@ -351,7 +351,8 @@ func TestGetDashboard(t *testing.T) { Version: 1, Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), } - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil).Once() + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil).Once() + k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{ Results: &resource.ResourceTable{ Columns: []*resource.ResourceTableColumnDefinition{ @@ -391,7 +392,7 @@ func TestGetDashboard(t *testing.T) { t.Run("Should return error when Kubernetes client fails", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything).Return(nil, assert.AnError).Once() + k8sCliMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything, mock.Anything).Return(nil, assert.AnError).Once() dashboard, err := service.GetDashboard(ctx, query) require.Error(t, err) @@ -401,7 +402,7 @@ func TestGetDashboard(t *testing.T) { t.Run("Should return dashboard not found if Kubernetes client returns nil", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything).Return(nil, nil).Once() + k8sCliMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Once() dashboard, err := service.GetDashboard(ctx, query) require.Error(t, err) require.Equal(t, dashboards.ErrDashboardNotFound, err) @@ -450,6 +451,7 @@ func TestGetAllDashboards(t *testing.T) { Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), } + k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) k8sCliMock.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{dashboardUnstructured}}, nil).Once() dashes, err := service.GetAllDashboards(ctx) @@ -501,6 +503,7 @@ func TestGetAllDashboardsByOrgId(t *testing.T) { Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), } + k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) k8sCliMock.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{dashboardUnstructured}}, nil).Once() dashes, err := service.GetAllDashboardsByOrgId(ctx, 1) @@ -534,7 +537,7 @@ func TestGetProvisionedDashboardData(t *testing.T) { t.Run("Should use Kubernetes client if feature flags are enabled and get from relevant org", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ "metadata": map[string]any{ "name": "uid", "labels": map[string]any{ @@ -632,7 +635,7 @@ func TestGetProvisionedDashboardDataByDashboardID(t *testing.T) { t.Run("Should use Kubernetes client if feature flags are enabled and get from whatever org it is in", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ "metadata": map[string]any{ "name": "uid", "labels": map[string]any{ @@ -721,7 +724,7 @@ func TestGetProvisionedDashboardDataByDashboardUID(t *testing.T) { t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ "metadata": map[string]any{ "name": "uid", "labels": map[string]any{ @@ -812,7 +815,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { fakeStore.On("CleanupAfterDelete", mock.Anything, &dashboards.DeleteDashboardCommand{UID: "uid", OrgID: 1}).Return(nil).Once() fakeStore.On("CleanupAfterDelete", mock.Anything, &dashboards.DeleteDashboardCommand{UID: "uid3", OrgID: 2}).Return(nil).Once() fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil) - k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ + k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ "metadata": map[string]any{ "name": "uid", "annotations": map[string]any{ @@ -825,7 +828,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { "spec": map[string]any{}, }}, nil).Once() // should not delete this one, because it does not start with "file:" - k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ + k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ "metadata": map[string]any{ "name": "uid2", "annotations": map[string]any{ @@ -836,7 +839,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { "spec": map[string]any{}, }}, nil).Once() - k8sCliMock.On("Get", mock.Anything, "uid3", mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ + k8sCliMock.On("Get", mock.Anything, "uid3", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ "metadata": map[string]any{ "name": "uid3", "annotations": map[string]any{ @@ -958,7 +961,7 @@ func TestUnprovisionDashboard(t *testing.T) { }, "spec": map[string]any{}, }} - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(dash, nil) + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(dash, nil) dashWithoutAnnotations := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "dashboard.grafana.app/v0alpha1", "kind": "Dashboard", @@ -975,6 +978,7 @@ func TestUnprovisionDashboard(t *testing.T) { // should update it to be without annotations k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything, mock.Anything).Return(dashWithoutAnnotations, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") + k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{ Results: &resource.ResourceTable{ Columns: []*resource.ResourceTableColumnDefinition{ @@ -1039,7 +1043,8 @@ func TestGetDashboardsByPluginID(t *testing.T) { t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything).Return(uidUnstructured, nil) + k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(uidUnstructured, nil) + k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == "plugin" && req.Options.Fields[1].Key == "repo.path" && req.Options.Fields[1].Values[0] == "testing" @@ -1127,7 +1132,8 @@ func TestSaveProvisionedDashboard(t *testing.T) { t.Run("Should use Kubernetes create if feature flags are enabled", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil) - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) + k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") @@ -1188,7 +1194,8 @@ func TestSaveDashboard(t *testing.T) { t.Run("Should use Kubernetes create if feature flags are enabled and dashboard doesn't exist", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) + k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) @@ -1199,7 +1206,8 @@ func TestSaveDashboard(t *testing.T) { t.Run("Should use Kubernetes update if feature flags are enabled and dashboard exists", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) @@ -1210,7 +1218,7 @@ func TestSaveDashboard(t *testing.T) { t.Run("Should return an error if uid is invalid", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) + k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) k8sCliMock.On("GetNamespace", mock.Anything).Return("default") k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) @@ -1495,8 +1503,9 @@ func TestGetDashboards(t *testing.T) { t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) - k8sCliMock.On("Get", mock.Anything, "uid1", mock.Anything, mock.Anything).Return(uid1Unstructured, nil) - k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything).Return(uid2Unstructured, nil) + k8sCliMock.On("Get", mock.Anything, "uid1", mock.Anything, mock.Anything, mock.Anything).Return(uid1Unstructured, nil) + k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything, mock.Anything).Return(uid2Unstructured, nil) + k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{ Results: &resource.ResourceTable{ Columns: []*resource.ResourceTableColumnDefinition{ @@ -1611,10 +1620,10 @@ func TestGetDashboardUIDByID(t *testing.T) { } func TestUnstructuredToLegacyDashboard(t *testing.T) { - fake := usertest.NewUserServiceFake() - fake.ExpectedUser = &user.User{ID: 10, UID: "useruid"} + k8sCliMock := new(client.MockK8sHandler) + k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{ID: 10, UID: "useruid"}, nil) dr := &DashboardServiceImpl{ - userService: fake, + k8sclient: k8sCliMock, } t.Run("successfully converts unstructured to legacy dashboard", func(t *testing.T) { uid := "36b7c825-79cc-435e-acf6-c78bd96a4510" @@ -1818,6 +1827,68 @@ func TestCountDashboardsInOrg(t *testing.T) { }) } +func TestCountInFolders(t *testing.T) { + fakeStore := dashboards.FakeDashboardStore{} + defer fakeStore.AssertExpectations(t) + service := &DashboardServiceImpl{ + cfg: setting.NewCfg(), + dashboardStore: &fakeStore, + } + dashs := &resource.ResourceSearchResponse{ + Results: &resource.ResourceTable{ + Columns: []*resource.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resource.ResourceTableColumnDefinition_STRING, + }, + { + Name: "folder", + Type: resource.ResourceTableColumnDefinition_STRING, + }, + }, + Rows: []*resource.ResourceTableRow{ + { + Key: &resource.ResourceKey{ + Name: "uid", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("Dashboard 1"), + []byte("folder 1"), + }, + }, + { + Key: &resource.ResourceKey{ + Name: "uid2", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("Dashboard 2"), + []byte("folder 1"), + }, + }, + }, + }, + TotalHits: 2, + } + + t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { + service.features = featuremgmt.WithFeatures() + fakeStore.On("CountDashboardsInFolders", mock.Anything, mock.Anything).Return(int64(1), nil).Once() + _, err := service.CountInFolders(context.Background(), 1, []string{"folder1"}, &user.SignedInUser{}) + require.NoError(t, err) + fakeStore.AssertExpectations(t) + }) + + t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { + ctx, k8sCliMock := setupK8sDashboardTests(service) + k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(dashs, nil).Once() + result, err := service.CountInFolders(ctx, 1, []string{"folder1"}, &user.SignedInUser{}) + require.NoError(t, err) + require.Equal(t, result, int64(2)) + }) +} + func TestLegacySaveCommandToUnstructured(t *testing.T) { namespace := "test-namespace" t.Run("successfully converts save command to unstructured", func(t *testing.T) { @@ -1850,17 +1921,3 @@ func TestLegacySaveCommandToUnstructured(t *testing.T) { assert.Equal(t, result.GetAnnotations(), map[string]string(nil)) }) } - -func TestToUID(t *testing.T) { - t.Run("parses valid UID", func(t *testing.T) { - rawIdentifier := "user:uid-value" - result := toUID(rawIdentifier) - assert.Equal(t, "uid-value", result) - }) - - t.Run("returns empty string for invalid identifier", func(t *testing.T) { - rawIdentifier := "invalid-uid" - result := toUID(rawIdentifier) - assert.Equal(t, "", result) - }) -} diff --git a/pkg/services/dashboardversion/dashver.go b/pkg/services/dashboardversion/dashver.go index 8609e238d55..9d4dcb60264 100644 --- a/pkg/services/dashboardversion/dashver.go +++ b/pkg/services/dashboardversion/dashver.go @@ -7,5 +7,5 @@ import ( type Service interface { Get(context.Context, *GetDashboardVersionQuery) (*DashboardVersionDTO, error) DeleteExpired(context.Context, *DeleteExpiredVersionsCommand) error - List(context.Context, *ListDashboardVersionsQuery) ([]*DashboardVersionDTO, error) + List(context.Context, *ListDashboardVersionsQuery) (*DashboardVersionResponse, error) } diff --git a/pkg/services/dashboardversion/dashverimpl/dashver.go b/pkg/services/dashboardversion/dashverimpl/dashver.go index 04fed84c9bd..02bed95b96e 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver.go @@ -3,12 +3,26 @@ package dashverimpl import ( "context" "errors" + "fmt" + "strconv" + "strings" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/apiserver" + "github.com/grafana/grafana/pkg/services/apiserver/client" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/resource" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) const ( @@ -17,19 +31,32 @@ const ( ) type Service struct { - cfg *setting.Cfg - store store - dashSvc dashboards.DashboardService - log log.Logger + cfg *setting.Cfg + store store + dashSvc dashboards.DashboardService + k8sclient client.K8sHandler + features featuremgmt.FeatureToggles + log log.Logger } -func ProvideService(cfg *setting.Cfg, db db.DB, dashboardService dashboards.DashboardService) dashver.Service { +func ProvideService(cfg *setting.Cfg, db db.DB, dashboardService dashboards.DashboardService, dashboardStore dashboards.Store, features featuremgmt.FeatureToggles, + restConfigProvider apiserver.RestConfigProvider, userService user.Service, unified resource.ResourceClient) dashver.Service { return &Service{ cfg: cfg, store: &sqlStore{ db: db, dialect: db.GetDialect(), }, + features: features, + k8sclient: client.NewK8sHandler( + cfg, + request.GetNamespaceMapper(cfg), + v0alpha1.DashboardResourceInfo.GroupVersionResource(), + restConfigProvider, + unified, + dashboardStore, + userService, + ), dashSvc: dashboardService, log: log.New("dashboard-version"), } @@ -49,13 +76,21 @@ func (s *Service) Get(ctx context.Context, query *dashver.GetDashboardVersionQue // versions table, at time of this writing), so get the DashboardID if it // was not populated. if query.DashboardID == 0 { - id, err := s.getDashIDMaybeEmpty(ctx, query.DashboardUID) + id, err := s.getDashIDMaybeEmpty(ctx, query.DashboardUID, query.OrgID) if err != nil { return nil, err } query.DashboardID = id } + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { + version, err := s.getHistoryThroughK8s(ctx, query.OrgID, query.DashboardUID, query.Version) + if err != nil { + return nil, err + } + return version, nil + } + version, err := s.store.Get(ctx, query) if err != nil { return nil, err @@ -95,7 +130,7 @@ func (s *Service) DeleteExpired(ctx context.Context, cmd *dashver.DeleteExpiredV } // List all dashboard versions for the given dashboard ID. -func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersionsQuery) ([]*dashver.DashboardVersionDTO, error) { +func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersionsQuery) (*dashver.DashboardVersionResponse, error) { // Get the DashboardUID if not populated if query.DashboardUID == "" { u, err := s.getDashUIDMaybeEmpty(ctx, query.DashboardID) @@ -109,7 +144,7 @@ func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersions // versions table, at time of this writing), so get the DashboardID if it // was not populated. if query.DashboardID == 0 { - id, err := s.getDashIDMaybeEmpty(ctx, query.DashboardUID) + id, err := s.getDashIDMaybeEmpty(ctx, query.DashboardUID, query.OrgID) if err != nil { return nil, err } @@ -118,6 +153,21 @@ func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersions if query.Limit == 0 { query.Limit = 1000 } + + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { + versions, err := s.listHistoryThroughK8s( + ctx, + query.OrgID, + query.DashboardUID, + int64(query.Limit), + query.ContinueToken, + ) + if err != nil { + return nil, err + } + return versions, nil + } + dvs, err := s.store.List(ctx, query) if err != nil { return nil, err @@ -126,7 +176,9 @@ func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersions for i, v := range dvs { dtos[i] = v.ToDTO(query.DashboardUID) } - return dtos, nil + return &dashver.DashboardVersionResponse{ + Versions: dtos, + }, nil } // getDashUIDMaybeEmpty is a helper function which takes a dashboardID and @@ -149,8 +201,8 @@ func (s *Service) getDashUIDMaybeEmpty(ctx context.Context, id int64) (string, e // getDashIDMaybeEmpty is a helper function which takes a dashboardUID and // returns the ID. If the dashboard is not found, it will return -1. -func (s *Service) getDashIDMaybeEmpty(ctx context.Context, uid string) (int64, error) { - q := dashboards.GetDashboardQuery{UID: uid} +func (s *Service) getDashIDMaybeEmpty(ctx context.Context, uid string, orgID int64) (int64, error) { + q := dashboards.GetDashboardQuery{UID: uid, OrgID: orgID} result, err := s.dashSvc.GetDashboard(ctx, &q) if err != nil { if errors.Is(err, dashboards.ErrDashboardNotFound) { @@ -163,3 +215,116 @@ func (s *Service) getDashIDMaybeEmpty(ctx context.Context, uid string) (int64, e } return result.ID, nil } + +func (s *Service) getHistoryThroughK8s(ctx context.Context, orgID int64, dashboardUID string, rv int64) (*dashver.DashboardVersionDTO, error) { + out, err := s.k8sclient.Get(ctx, dashboardUID, orgID, v1.GetOptions{ResourceVersion: strconv.FormatInt(rv, 10)}) + if err != nil { + return nil, err + } else if out == nil { + return nil, dashboards.ErrDashboardNotFound + } + + dash, err := s.UnstructuredToLegacyDashboardVersion(ctx, out, orgID) + if err != nil { + return nil, err + } + + return dash, nil +} + +func (s *Service) listHistoryThroughK8s(ctx context.Context, orgID int64, dashboardUID string, limit int64, continueToken string) (*dashver.DashboardVersionResponse, error) { + out, err := s.k8sclient.List(ctx, orgID, v1.ListOptions{ + LabelSelector: utils.LabelKeyGetHistory + "=" + dashboardUID, + Limit: limit, + Continue: continueToken, + }) + if err != nil { + return nil, err + } else if out == nil { + return nil, dashboards.ErrDashboardNotFound + } + + dashboards := make([]*dashver.DashboardVersionDTO, len(out.Items)) + for i, item := range out.Items { + dash, err := s.UnstructuredToLegacyDashboardVersion(ctx, &item, orgID) + if err != nil { + return nil, err + } + dashboards[i] = dash + } + + return &dashver.DashboardVersionResponse{ + ContinueToken: out.GetContinue(), + Versions: dashboards, + }, nil +} + +func (s *Service) UnstructuredToLegacyDashboardVersion(ctx context.Context, item *unstructured.Unstructured, orgID int64) (*dashver.DashboardVersionDTO, error) { + spec, ok := item.Object["spec"].(map[string]any) + if !ok { + return nil, errors.New("error parsing dashboard from k8s response") + } + obj, err := utils.MetaAccessor(item) + if err != nil { + return nil, err + } + uid := obj.GetName() + spec["uid"] = uid + + dashVersion := 0 + parentVersion := 0 + if version, ok := spec["version"].(int64); ok { + dashVersion = int(version) + parentVersion = dashVersion - 1 + } + + createdBy, err := s.k8sclient.GetUserFromMeta(ctx, obj.GetCreatedBy()) + if err != nil { + return nil, err + } + + id, err := obj.GetResourceVersionInt64() + if err != nil { + return nil, err + } + + restoreVer, err := getRestoreVersion(obj.GetMessage()) + if err != nil { + return nil, err + } + + out := dashver.DashboardVersionDTO{ + ID: id, + DashboardID: obj.GetDeprecatedInternalID(), // nolint:staticcheck + DashboardUID: uid, + Created: obj.GetCreationTimestamp().Time, + CreatedBy: createdBy.ID, + Message: obj.GetMessage(), + RestoredFrom: restoreVer, + Version: dashVersion, + ParentVersion: parentVersion, + Data: simplejson.NewFromAny(spec), + } + + return &out, nil +} + +var restoreMsg = "Restored from version " + +func DashboardRestoreMessage(version int) string { + return fmt.Sprintf("%s%d", restoreMsg, version) +} + +func getRestoreVersion(msg string) (int, error) { + parts := strings.Split(msg, restoreMsg) + if len(parts) < 2 { + return 0, nil + } + + ver, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return 0, err + } + + return int(ver), nil +} diff --git a/pkg/services/dashboardversion/dashverimpl/dashver_test.go b/pkg/services/dashboardversion/dashverimpl/dashver_test.go index 7ec8fc4ef64..ed58c27bb7f 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver_test.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver_test.go @@ -7,18 +7,24 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/apiserver/client" "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) func TestDashboardVersionService(t *testing.T) { dashboardVersionStore := newDashboardVersionStoreFake() dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService} + dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, features: featuremgmt.WithFeatures()} t.Run("Get dashboard version", func(t *testing.T) { dashboard := &dashver.DashboardVersion{ @@ -32,6 +38,44 @@ func TestDashboardVersionService(t *testing.T) { require.NoError(t, err) require.Equal(t, dashboard.ToDTO("uid"), dashboardVersion) }) + + t.Run("Get dashboard version through k8s", func(t *testing.T) { + dashboardService := dashboards.NewFakeDashboardService(t) + dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()} + mockCli := new(client.MockK8sHandler) + dashboardVersionService.k8sclient = mockCli + dashboardVersionService.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesCliDashboards) + dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).Return(&dashboards.DashboardRef{UID: "uid"}, nil) + + mockCli.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) + mockCli.On("Get", mock.Anything, "uid", int64(1), v1.GetOptions{ResourceVersion: "10"}, mock.Anything).Return(&unstructured.Unstructured{ + Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid", + "resourceVersion": "12", + "labels": map[string]any{ + utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck + }, + }, + "spec": map[string]any{ + "version": int64(10), + }, + }}, nil).Once() + res, err := dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{ + DashboardID: 42, + OrgID: 1, + Version: 10, + }) + require.Nil(t, err) + require.Equal(t, res, &dashver.DashboardVersionDTO{ + ID: 12, // RV should be used + Version: 10, + ParentVersion: 9, + DashboardID: 42, + DashboardUID: "uid", + Data: simplejson.NewFromAny(map[string]any{"uid": "uid", "version": int64(10)}), + }) + }) } func TestDeleteExpiredVersions(t *testing.T) { @@ -42,7 +86,7 @@ func TestDeleteExpiredVersions(t *testing.T) { dashboardVersionStore := newDashboardVersionStoreFake() dashboardService := dashboards.NewFakeDashboardService(t) dashboardVersionService := Service{ - cfg: cfg, store: dashboardVersionStore, dashSvc: dashboardService} + cfg: cfg, store: dashboardVersionStore, dashSvc: dashboardService, features: featuremgmt.WithFeatures()} t.Run("Don't delete anything if there are no expired versions", func(t *testing.T) { err := dashboardVersionService.DeleteExpired(context.Background(), &dashver.DeleteExpiredVersionsCommand{DeletedRows: 4}) @@ -67,7 +111,7 @@ func TestListDashboardVersions(t *testing.T) { t.Run("List all versions for a given Dashboard ID", func(t *testing.T) { dashboardVersionStore := newDashboardVersionStoreFake() dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService} + dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, features: featuremgmt.WithFeatures()} dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{ {ID: 1, DashboardID: 42}, } @@ -78,15 +122,15 @@ func TestListDashboardVersions(t *testing.T) { query := dashver.ListDashboardVersionsQuery{DashboardID: 42} res, err := dashboardVersionService.List(context.Background(), &query) require.Nil(t, err) - require.Equal(t, 1, len(res)) + require.Equal(t, 1, len(res.Versions)) // validate that the UID was populated - require.EqualValues(t, []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}, res) + require.EqualValues(t, &dashver.DashboardVersionResponse{Versions: []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}}, res) }) t.Run("List all versions for a non-existent DashboardID", func(t *testing.T) { dashboardVersionStore := newDashboardVersionStoreFake() dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger()} + dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger(), features: featuremgmt.WithFeatures()} dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{ {ID: 1, DashboardID: 42}, } @@ -96,15 +140,15 @@ func TestListDashboardVersions(t *testing.T) { query := dashver.ListDashboardVersionsQuery{DashboardID: 42} res, err := dashboardVersionService.List(context.Background(), &query) require.Nil(t, err) - require.Equal(t, 1, len(res)) + require.Equal(t, 1, len(res.Versions)) // The DashboardID remains populated with the given value, even though the dash was not found - require.EqualValues(t, []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42}}, res) + require.EqualValues(t, &dashver.DashboardVersionResponse{Versions: []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42}}}, res) }) t.Run("List all versions for a given DashboardUID", func(t *testing.T) { dashboardVersionStore := newDashboardVersionStoreFake() dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger()} + dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger(), features: featuremgmt.WithFeatures()} dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{{DashboardID: 42, ID: 1}} dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")). Return(&dashboards.Dashboard{ID: 42}, nil) @@ -112,15 +156,15 @@ func TestListDashboardVersions(t *testing.T) { query := dashver.ListDashboardVersionsQuery{DashboardUID: "uid"} res, err := dashboardVersionService.List(context.Background(), &query) require.Nil(t, err) - require.Equal(t, 1, len(res)) + require.Equal(t, 1, len(res.Versions)) // validate that the dashboardID was populated from the GetDashboard method call. - require.EqualValues(t, []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}, res) + require.EqualValues(t, &dashver.DashboardVersionResponse{Versions: []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}}, res) }) t.Run("List all versions for a given non-existent DashboardUID", func(t *testing.T) { dashboardVersionStore := newDashboardVersionStoreFake() dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger()} + dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger(), features: featuremgmt.WithFeatures()} dashboardVersionStore.ExpectedListVersions = []*dashver.DashboardVersion{{DashboardID: 42, ID: 1}} dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")). Return(nil, dashboards.ErrDashboardNotFound) @@ -128,15 +172,15 @@ func TestListDashboardVersions(t *testing.T) { query := dashver.ListDashboardVersionsQuery{DashboardUID: "uid"} res, err := dashboardVersionService.List(context.Background(), &query) require.Nil(t, err) - require.Equal(t, 1, len(res)) + require.Equal(t, 1, len(res.Versions)) // validate that the dashboardUID & ID are populated, even though the dash was not found - require.EqualValues(t, []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}, res) + require.EqualValues(t, &dashver.DashboardVersionResponse{Versions: []*dashver.DashboardVersionDTO{{ID: 1, DashboardID: 42, DashboardUID: "uid"}}}, res) }) t.Run("List Dashboard versions - error from store", func(t *testing.T) { dashboardVersionStore := newDashboardVersionStoreFake() dashboardService := dashboards.NewFakeDashboardService(t) - dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger()} + dashboardVersionService := Service{store: dashboardVersionStore, dashSvc: dashboardService, log: log.NewNopLogger(), features: featuremgmt.WithFeatures()} dashboardVersionStore.ExpectedError = dashver.ErrDashboardVersionNotFound query := dashver.ListDashboardVersionsQuery{DashboardID: 42, DashboardUID: "42"} @@ -144,6 +188,46 @@ func TestListDashboardVersions(t *testing.T) { require.Nil(t, res) require.ErrorIs(t, err, dashver.ErrDashboardVersionNotFound) }) + + t.Run("List all versions for a given Dashboard ID through k8s", func(t *testing.T) { + dashboardService := dashboards.NewFakeDashboardService(t) + dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()} + mockCli := new(client.MockK8sHandler) + dashboardVersionService.k8sclient = mockCli + dashboardVersionService.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesCliDashboards) + + dashboardService.On("GetDashboardUIDByID", mock.Anything, + mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")). + Return(&dashboards.DashboardRef{UID: "uid"}, nil) + + query := dashver.ListDashboardVersionsQuery{DashboardID: 42} + mockCli.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) + mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{{Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid", + "resourceVersion": "12", + "labels": map[string]any{ + utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck + }, + }, + "spec": map[string]any{ + "version": int64(5), + }, + }}}}, nil).Once() + res, err := dashboardVersionService.List(context.Background(), &query) + require.Nil(t, err) + require.Equal(t, 1, len(res.Versions)) + require.EqualValues(t, &dashver.DashboardVersionResponse{ + Versions: []*dashver.DashboardVersionDTO{{ + ID: 12, // should take rv + DashboardID: 42, + ParentVersion: 4, + Version: 5, // should take from spec + DashboardUID: "uid", + Data: simplejson.NewFromAny(map[string]any{"uid": "uid", "version": int64(5)}), + }}}, res) + }) } type FakeDashboardVersionStore struct { diff --git a/pkg/services/dashboardversion/dashverimpl/store_test.go b/pkg/services/dashboardversion/dashverimpl/store_test.go index cccd1d220ce..a8c26b1d102 100644 --- a/pkg/services/dashboardversion/dashverimpl/store_test.go +++ b/pkg/services/dashboardversion/dashverimpl/store_test.go @@ -34,7 +34,7 @@ func testIntegrationGetDashboardVersion(t *testing.T, fn getStore) { query := dashver.GetDashboardVersionQuery{ DashboardID: savedDash.ID, - Version: savedDash.Version, + Version: int64(savedDash.Version), OrgID: 1, } @@ -60,7 +60,7 @@ func testIntegrationGetDashboardVersion(t *testing.T, fn getStore) { t.Run("Attempt to get a version that doesn't exist", func(t *testing.T) { query := dashver.GetDashboardVersionQuery{ DashboardID: int64(999), - Version: 123, + Version: int64(123), OrgID: 1, } diff --git a/pkg/services/dashboardversion/dashvertest/fake.go b/pkg/services/dashboardversion/dashvertest/fake.go index 1d45c63aa3c..acef53263bc 100644 --- a/pkg/services/dashboardversion/dashvertest/fake.go +++ b/pkg/services/dashboardversion/dashvertest/fake.go @@ -10,6 +10,7 @@ type FakeDashboardVersionService struct { ExpectedDashboardVersion *dashver.DashboardVersionDTO ExpectedDashboardVersions []*dashver.DashboardVersionDTO ExpectedListDashboarVersions []*dashver.DashboardVersionDTO + ExpectedContinueToken string counter int ExpectedError error } @@ -30,6 +31,9 @@ func (f *FakeDashboardVersionService) DeleteExpired(ctx context.Context, cmd *da return f.ExpectedError } -func (f *FakeDashboardVersionService) List(ctx context.Context, query *dashver.ListDashboardVersionsQuery) ([]*dashver.DashboardVersionDTO, error) { - return f.ExpectedListDashboarVersions, f.ExpectedError +func (f *FakeDashboardVersionService) List(ctx context.Context, query *dashver.ListDashboardVersionsQuery) (*dashver.DashboardVersionResponse, error) { + return &dashver.DashboardVersionResponse{ + ContinueToken: f.ExpectedContinueToken, + Versions: f.ExpectedListDashboarVersions, + }, f.ExpectedError } diff --git a/pkg/services/dashboardversion/model.go b/pkg/services/dashboardversion/model.go index 435fc794311..c4f585dc366 100644 --- a/pkg/services/dashboardversion/model.go +++ b/pkg/services/dashboardversion/model.go @@ -52,7 +52,7 @@ type GetDashboardVersionQuery struct { DashboardID int64 DashboardUID string OrgID int64 - Version int + Version int64 } type DeleteExpiredVersionsCommand struct { @@ -60,12 +60,19 @@ type DeleteExpiredVersionsCommand struct { } type ListDashboardVersionsQuery struct { - DashboardID int64 - DashboardUID string - OrgID int64 - Limit int - Start int + DashboardID int64 + DashboardUID string + OrgID int64 + Limit int + Start int + ContinueToken string } + +type DashboardVersionResponse struct { + ContinueToken string `json:"continueToken"` + Versions []*DashboardVersionDTO `json:"versions"` +} + type DashboardVersionDTO struct { ID int64 `json:"id"` DashboardID int64 `json:"dashboardId"` @@ -94,3 +101,8 @@ type DashboardVersionMeta struct { Data *simplejson.Json `json:"data"` CreatedBy string `json:"createdBy"` } + +type DashboardVersionResponseMeta struct { + ContinueToken string `json:"continueToken"` + Versions []DashboardVersionMeta `json:"versions"` +} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index a717b3bb707..a4baa2652bf 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1587,13 +1587,6 @@ var ( Owner: grafanaPluginsPlatformSquad, RequiresRestart: true, }, - { - Name: "zipkinBackendMigration", - Description: "Enables querying Zipkin data source without the proxy", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaOSSBigTent, - Expression: "true", // enabled by default - }, { Name: "enableSCIM", Description: "Enables SCIM support for user and group management", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 5a23134e827..34f40c2585d 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -210,7 +210,6 @@ passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,f exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true prometheusSpecialCharsInLabelValues,experimental,@grafana/oss-big-tent,false,false,true enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false -zipkinBackendMigration,GA,@grafana/oss-big-tent,false,false,false enableSCIM,experimental,@grafana/identity-access-team,false,false,false crashDetection,experimental,@grafana/observability-traces-and-profiling,false,false,true jaegerBackendMigration,experimental,@grafana/oss-big-tent,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 6ca1cd23fef..06bc4b00564 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -851,10 +851,6 @@ const ( // Enables the extension admin page regardless of development mode FlagEnableExtensionsAdminPage = "enableExtensionsAdminPage" - // FlagZipkinBackendMigration - // Enables querying Zipkin data source without the proxy - FlagZipkinBackendMigration = "zipkinBackendMigration" - // FlagEnableSCIM // Enables SCIM support for user and group management FlagEnableSCIM = "enableSCIM" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 0ac61ba717c..7ef236e5194 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -4062,6 +4062,7 @@ "name": "zipkinBackendMigration", "resourceVersion": "1733846643829", "creationTimestamp": "2024-11-07T09:35:53Z", + "deletionTimestamp": "2025-01-27T11:47:54Z", "annotations": { "grafana.app/updatedTimestamp": "2024-12-10 16:04:03.82919 +0000 UTC" } diff --git a/pkg/services/folder/folderimpl/conversions.go b/pkg/services/folder/folderimpl/conversions.go new file mode 100644 index 00000000000..29ef06d14fc --- /dev/null +++ b/pkg/services/folder/folderimpl/conversions.go @@ -0,0 +1,104 @@ +package folderimpl + +import ( + "context" + "errors" + "strconv" + "strings" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/infra/slugify" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/user" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func (ss *FolderUnifiedStoreImpl) UnstructuredToLegacyFolder(ctx context.Context, item *unstructured.Unstructured) (*folder.Folder, error) { + meta, err := utils.MetaAccessor(item) + if err != nil { + return nil, err + } + + info, _ := authlib.ParseNamespace(meta.GetNamespace()) + if info.OrgID < 0 { + info.OrgID = 1 // This resolves all test cases that assume org 1 + } + + title, _, _ := unstructured.NestedString(item.Object, "spec", "title") + description, _, _ := unstructured.NestedString(item.Object, "spec", "description") + + uid := meta.GetName() + url := "" + if uid != folder.RootFolder.UID { + slug := slugify.Slugify(title) + url = dashboards.GetFolderURL(uid, slug) + } + + created := meta.GetCreationTimestamp().Time.UTC() + updated, _ := meta.GetUpdatedTimestamp() + if updated == nil { + updated = &created + } else { + tmp := updated.UTC() + updated = &tmp + } + + creator, err := ss.getUserFromMeta(ctx, meta.GetCreatedBy()) + if err != nil { + return nil, err + } + + updater, err := ss.getUserFromMeta(ctx, meta.GetUpdatedBy()) + if err != nil { + return nil, err + } + if updater.UID == "" { + updater = creator + } + + return &folder.Folder{ + UID: uid, + Title: title, + Description: description, + ID: meta.GetDeprecatedInternalID(), // nolint:staticcheck + ParentUID: meta.GetFolder(), + Version: int(meta.GetGeneration()), + Repository: meta.GetRepositoryName(), + + URL: url, + Created: created, + Updated: *updated, + OrgID: info.OrgID, + CreatedBy: creator.ID, + UpdatedBy: updater.ID, + }, nil +} + +func (ss *FolderUnifiedStoreImpl) getUserFromMeta(ctx context.Context, userMeta string) (*user.User, error) { + if userMeta == "" || toUID(userMeta) == "" { + return &user.User{}, nil + } + usr, err := ss.getUser(ctx, toUID(userMeta)) + if err != nil && errors.Is(err, user.ErrUserNotFound) { + return &user.User{}, nil + } + return usr, err +} + +func (ss *FolderUnifiedStoreImpl) getUser(ctx context.Context, uid string) (*user.User, error) { + userID, err := strconv.ParseInt(uid, 10, 64) + if err == nil { + return ss.userService.GetByID(ctx, &user.GetUserByIDQuery{ID: userID}) + } + return ss.userService.GetByUID(ctx, &user.GetUserByUIDQuery{UID: uid}) +} + +func toUID(rawIdentifier string) string { + parts := strings.Split(rawIdentifier, ":") + if len(parts) < 2 { + return "" + } + return parts[1] +} diff --git a/pkg/registry/apis/folders/conversions_test.go b/pkg/services/folder/folderimpl/conversions_test.go similarity index 76% rename from pkg/registry/apis/folders/conversions_test.go rename to pkg/services/folder/folderimpl/conversions_test.go index f7bef8ba015..a6120c7c23d 100644 --- a/pkg/registry/apis/folders/conversions_test.go +++ b/pkg/services/folder/folderimpl/conversions_test.go @@ -1,6 +1,7 @@ -package folders +package folderimpl import ( + "context" "testing" "time" @@ -8,6 +9,8 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/usertest" ) func TestFolderConversions(t *testing.T) { @@ -29,8 +32,8 @@ func TestFolderConversions(t *testing.T) { "grafana.app/folder": "parent-folder-name", "grafana.app/updatedTimestamp": "2022-12-02T07:02:02Z", "grafana.app/repoName": "example-repo", - "grafana.app/createdBy": "user:abc", - "grafana.app/updatedBy": "service:xyz" + "grafana.app/createdBy": "user:useruid", + "grafana.app/updatedBy": "user:useruid" } }, "spec": { @@ -44,7 +47,12 @@ func TestFolderConversions(t *testing.T) { created = created.UTC() require.NoError(t, err) - converted, err := UnstructuredToLegacyFolder(input) + fake := usertest.NewUserServiceFake() + fake.ExpectedUser = &user.User{ID: 10, UID: "useruid"} + + fs := ProvideUnifiedStore(nil, fake) + + converted, err := fs.UnstructuredToLegacyFolder(context.Background(), input) require.NoError(t, err) require.Equal(t, folder.Folder{ ID: 234, @@ -58,5 +66,7 @@ func TestFolderConversions(t *testing.T) { Repository: "example-repo", Created: created, Updated: created.Add(time.Hour * 5), + CreatedBy: 10, + UpdatedBy: 10, }, *converted) } diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 9c918f9d360..cafa0e38c91 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -53,7 +53,6 @@ type Service struct { log *slog.Logger dashboardStore dashboards.Store dashboardFolderStore folder.FolderStore - userService user.Service features featuremgmt.FeatureToggles accessControl accesscontrol.AccessControl k8sclient folderK8sHandler @@ -88,7 +87,6 @@ func ProvideService( dashboardStore: dashboardStore, dashboardFolderStore: folderStore, store: store, - userService: userService, features: features, accessControl: ac, bus: bus, @@ -114,7 +112,7 @@ func ProvideService( recourceClientProvider: unified.GetResourceClient, } - unifiedStore := ProvideUnifiedStore(k8sHandler) + unifiedStore := ProvideUnifiedStore(k8sHandler, userService) srv.unifiedStore = unifiedStore srv.k8sclient = k8sHandler diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go index c9e9f536d17..3d5a84a6aad 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go @@ -35,6 +35,7 @@ import ( "github.com/grafana/grafana/pkg/services/publicdashboards" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/storage/unified/resource" ) @@ -181,7 +182,11 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { recourceClientProvider: f, } - unifiedStore := ProvideUnifiedStore(k8sHandler) + userService := &usertest.FakeUserService{ + ExpectedUser: &user.User{}, + } + + unifiedStore := ProvideUnifiedStore(k8sHandler, userService) ctx := context.Background() usr := &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{ diff --git a/pkg/services/folder/folderimpl/unifiedstore.go b/pkg/services/folder/folderimpl/unifiedstore.go index fe835c16f7a..18c40751185 100644 --- a/pkg/services/folder/folderimpl/unifiedstore.go +++ b/pkg/services/folder/folderimpl/unifiedstore.go @@ -20,21 +20,24 @@ import ( internalfolders "github.com/grafana/grafana/pkg/registry/apis/folders" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" ) type FolderUnifiedStoreImpl struct { - log log.Logger - k8sclient folderK8sHandler + log log.Logger + k8sclient folderK8sHandler + userService user.Service } // sqlStore implements the store interface. var _ folder.Store = (*FolderUnifiedStoreImpl)(nil) -func ProvideUnifiedStore(k8sHandler *foldk8sHandler) *FolderUnifiedStoreImpl { +func ProvideUnifiedStore(k8sHandler *foldk8sHandler, userService user.Service) *FolderUnifiedStoreImpl { return &FolderUnifiedStoreImpl{ - k8sclient: k8sHandler, - log: log.New("folder-store"), + k8sclient: k8sHandler, + log: log.New("folder-store"), + userService: userService, } } @@ -60,7 +63,7 @@ func (ss *FolderUnifiedStoreImpl) Create(ctx context.Context, cmd folder.CreateF return nil, err } - folder, err := internalfolders.UnstructuredToLegacyFolder(out) + folder, err := ss.UnstructuredToLegacyFolder(ctx, out) if err != nil { return nil, err } @@ -135,7 +138,7 @@ func (ss *FolderUnifiedStoreImpl) Update(ctx context.Context, cmd folder.UpdateF return nil, err } - return internalfolders.UnstructuredToLegacyFolder(out) + return ss.UnstructuredToLegacyFolder(ctx, out) } // If WithFullpath is true it computes also the full path of a folder. @@ -178,7 +181,7 @@ func (ss *FolderUnifiedStoreImpl) Get(ctx context.Context, q folder.GetFolderQue return nil, dashboards.ErrFolderNotFound } - return internalfolders.UnstructuredToLegacyFolder(out) + return ss.UnstructuredToLegacyFolder(ctx, out) } func (ss *FolderUnifiedStoreImpl) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { @@ -206,7 +209,7 @@ func (ss *FolderUnifiedStoreImpl) GetParents(ctx context.Context, q folder.GetPa return nil, err } - folder, err := internalfolders.UnstructuredToLegacyFolder(out) + folder, err := ss.UnstructuredToLegacyFolder(ctx, out) if err != nil { return nil, err } @@ -245,9 +248,9 @@ func (ss *FolderUnifiedStoreImpl) GetChildren(ctx context.Context, q folder.GetC hits := make([]*folder.Folder, 0) for _, item := range out.Items { // convert item to legacy folder format - f, err := internalfolders.UnstructuredToLegacyFolder(&item) + f, err := ss.UnstructuredToLegacyFolder(ctx, &item) if f == nil { - return nil, fmt.Errorf("unable covert unstructured item to legacy folder %w", err) + return nil, fmt.Errorf("unable to convert unstructured item to legacy folder %w", err) } // it we are at root level, skip subfolder @@ -345,9 +348,9 @@ func (ss *FolderUnifiedStoreImpl) GetFolders(ctx context.Context, q folder.GetFo m := map[string]*folder.Folder{} for _, item := range out.Items { // convert item to legacy folder format - f, err := internalfolders.UnstructuredToLegacyFolder(&item) + f, err := ss.UnstructuredToLegacyFolder(ctx, &item) if f == nil { - return nil, fmt.Errorf("unable covert unstructured item to legacy folder %w", err) + return nil, fmt.Errorf("unable to convert unstructured item to legacy folder %w", err) } m[f.UID] = f @@ -405,9 +408,9 @@ func (ss *FolderUnifiedStoreImpl) GetDescendants(ctx context.Context, orgID int6 nodes := map[string]*folder.Folder{} for _, item := range out.Items { // convert item to legacy folder format - f, err := internalfolders.UnstructuredToLegacyFolder(&item) + f, err := ss.UnstructuredToLegacyFolder(ctx, &item) if f == nil { - return nil, fmt.Errorf("unable covert unstructured item to legacy folder %w", err) + return nil, fmt.Errorf("unable to convert unstructured item to legacy folder %w", err) } nodes[f.UID] = f diff --git a/pkg/services/ngalert/api/api_prometheus.go b/pkg/services/ngalert/api/api_prometheus.go index 22d09a91668..364a7027253 100644 --- a/pkg/services/ngalert/api/api_prometheus.go +++ b/pkg/services/ngalert/api/api_prometheus.go @@ -334,6 +334,11 @@ func PrepareRuleGroupStatuses(log log.Logger, manager state.AlertInstanceManager maxGroups := getInt64WithDefault(opts.Query, "group_limit", -1) nextToken := opts.Query.Get("group_next_token") + if nextToken != "" { + if _, err := base64.URLEncoding.DecodeString(nextToken); err != nil { + nextToken = "" + } + } groupedRules := getGroupedRules(log, ruleList, ruleNamesSet, opts.Namespaces) rulesTotals := make(map[string]int64, len(groupedRules)) diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 120755d2252..8ac41c3606e 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -790,8 +790,8 @@ func TestRouteGetRuleStatuses(t *testing.T) { } }) - t.Run("bad token should return no results", func(t *testing.T) { - r, err := http.NewRequest("GET", "/api/v1/rules?group_limit=10&group_next_token=foobar", nil) + t.Run("bad token should return first group_limit results", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?group_limit=1&group_next_token=foobar", nil) require.NoError(t, err) c.Context = &web.Context{Req: r} @@ -801,7 +801,14 @@ func TestRouteGetRuleStatuses(t *testing.T) { result := &apimodels.RuleResponse{} require.NoError(t, json.Unmarshal(resp.Body(), result)) - require.Len(t, result.Data.RuleGroups, 0) + require.Len(t, result.Data.RuleGroups, 1) + require.Len(t, result.Data.Totals, 0) + require.NotEmpty(t, result.Data.NextToken) + + folder, err := api.store.GetNamespaceByUID(context.Background(), "namespace_0", orgID, user) + require.NoError(t, err) + require.Equal(t, folder.Fullpath, result.Data.RuleGroups[0].File) + require.Equal(t, "rule_group_0", result.Data.RuleGroups[0].Name) }) t.Run("should return nothing when using group_limit=0", func(t *testing.T) { diff --git a/pkg/services/ngalert/schedule/alert_rule.go b/pkg/services/ngalert/schedule/alert_rule.go index 8f56fdfa8a9..f4a301aff78 100644 --- a/pkg/services/ngalert/schedule/alert_rule.go +++ b/pkg/services/ngalert/schedule/alert_rule.go @@ -42,6 +42,8 @@ type Rule interface { Type() ngmodels.RuleType // Status indicates the status of the evaluating rule. Status() ngmodels.RuleStatus + // Identifier returns the identifier of the rule. + Identifier() ngmodels.AlertRuleKeyWithGroup } type ruleFactoryFunc func(context.Context, *ngmodels.AlertRule) Rule @@ -71,7 +73,7 @@ func newRuleFactory( if rule.Type() == ngmodels.RuleTypeRecording { return newRecordingRule( ctx, - rule.GetKey(), + rule.GetKeyWithGroup(), maxAttempts, clock, evalFactory, @@ -178,6 +180,10 @@ func newAlertRule( } } +func (a *alertRule) Identifier() ngmodels.AlertRuleKeyWithGroup { + return a.key +} + func (a *alertRule) Type() ngmodels.RuleType { return ngmodels.RuleTypeAlerting } diff --git a/pkg/services/ngalert/schedule/alert_rule_test.go b/pkg/services/ngalert/schedule/alert_rule_test.go index bb9e648c291..4593a4052e0 100644 --- a/pkg/services/ngalert/schedule/alert_rule_test.go +++ b/pkg/services/ngalert/schedule/alert_rule_test.go @@ -269,6 +269,14 @@ func TestAlertRule(t *testing.T) { }) } +func TestAlertRuleIdentifier(t *testing.T) { + t.Run("should return correct identifier", func(t *testing.T) { + key := models.GenerateRuleKeyWithGroup(1) + r := blankRuleForTests(context.Background(), key) + require.Equal(t, key, r.Identifier()) + }) +} + func blankRuleForTests(ctx context.Context, key models.AlertRuleKeyWithGroup) *alertRule { return newAlertRule(ctx, key, nil, false, 0, nil, nil, nil, nil, nil, nil, log.NewNopLogger(), nil, nil, nil) } diff --git a/pkg/services/ngalert/schedule/recording_rule.go b/pkg/services/ngalert/schedule/recording_rule.go index 3abf44b6457..954b0869a4a 100644 --- a/pkg/services/ngalert/schedule/recording_rule.go +++ b/pkg/services/ngalert/schedule/recording_rule.go @@ -30,7 +30,7 @@ type RuleStatus struct { } type recordingRule struct { - key ngmodels.AlertRuleKey + key ngmodels.AlertRuleKeyWithGroup ctx context.Context evalCh chan *Evaluation @@ -56,8 +56,8 @@ type recordingRule struct { tracer tracing.Tracer } -func newRecordingRule(parent context.Context, key ngmodels.AlertRuleKey, maxAttempts int64, clock clock.Clock, evalFactory eval.EvaluatorFactory, cfg setting.RecordingRuleSettings, logger log.Logger, metrics *metrics.Scheduler, tracer tracing.Tracer, writer RecordingWriter, evalAppliedHook evalAppliedFunc, stopAppliedHook stopAppliedFunc) *recordingRule { - ctx, stop := util.WithCancelCause(ngmodels.WithRuleKey(parent, key)) +func newRecordingRule(parent context.Context, key ngmodels.AlertRuleKeyWithGroup, maxAttempts int64, clock clock.Clock, evalFactory eval.EvaluatorFactory, cfg setting.RecordingRuleSettings, logger log.Logger, metrics *metrics.Scheduler, tracer tracing.Tracer, writer RecordingWriter, evalAppliedHook evalAppliedFunc, stopAppliedHook stopAppliedFunc) *recordingRule { + ctx, stop := util.WithCancelCause(ngmodels.WithRuleKey(parent, key.AlertRuleKey)) return &recordingRule{ key: key, ctx: ctx, @@ -80,6 +80,10 @@ func newRecordingRule(parent context.Context, key ngmodels.AlertRuleKey, maxAtte } } +func (r *recordingRule) Identifier() ngmodels.AlertRuleKeyWithGroup { + return r.key +} + func (r *recordingRule) Type() ngmodels.RuleType { return ngmodels.RuleTypeRecording } @@ -301,7 +305,7 @@ func (r *recordingRule) evaluationDoneTestHook(ev *Evaluation) { return } - r.evalAppliedHook(r.key, ev.scheduledAt) + r.evalAppliedHook(r.key.AlertRuleKey, ev.scheduledAt) } // frameRef gets frames from a QueryDataResponse for a particular refID. It returns an error if the frames do not exist or have no data. @@ -328,5 +332,5 @@ func (r *recordingRule) stopApplied() { return } - r.stopAppliedHook(r.key) + r.stopAppliedHook(r.key.AlertRuleKey) } diff --git a/pkg/services/ngalert/schedule/recording_rule_test.go b/pkg/services/ngalert/schedule/recording_rule_test.go index 3a85921fdfe..7e72453bb9f 100644 --- a/pkg/services/ngalert/schedule/recording_rule_test.go +++ b/pkg/services/ngalert/schedule/recording_rule_test.go @@ -152,11 +152,20 @@ func TestRecordingRule(t *testing.T) { }) } +func TestRecordingRuleIdentifier(t *testing.T) { + t.Run("should return correct identifier", func(t *testing.T) { + key := models.GenerateRuleKeyWithGroup(1) + r := blankRecordingRuleForTests(context.Background()) + r.key = key + require.Equal(t, key, r.Identifier()) + }) +} + func blankRecordingRuleForTests(ctx context.Context) *recordingRule { st := setting.RecordingRuleSettings{ Enabled: true, } - return newRecordingRule(context.Background(), models.AlertRuleKey{}, 0, nil, nil, st, log.NewNopLogger(), nil, nil, writer.FakeWriter{}, nil, nil) + return newRecordingRule(context.Background(), models.AlertRuleKeyWithGroup{}, 0, nil, nil, st, log.NewNopLogger(), nil, nil, writer.FakeWriter{}, nil, nil) } func TestRecordingRule_Integration(t *testing.T) { diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index e02755eb0ec..3bc21115ca0 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -198,7 +198,7 @@ func (sch *schedule) deleteAlertRule(ctx context.Context, keys ...ngmodels.Alert // It can happen that the scheduler has deleted the alert rule before the // Ruler API has called DeleteAlertRule. This can happen as requests to // the Ruler API do not hold an exclusive lock over all scheduler operations. - rule, ok := sch.schedulableAlertRules.del(key) + _, ok := sch.schedulableAlertRules.del(key) if !ok { sch.log.Info("Alert rule cannot be removed from the scheduler as it is not scheduled", key.LogContext()...) } @@ -210,7 +210,7 @@ func (sch *schedule) deleteAlertRule(ctx context.Context, keys ...ngmodels.Alert } // stop rule evaluation - reason := sch.getRuleStopReason(ctx, key, rule) + reason := sch.getRuleStopReason(ctx, ruleRoutine.Identifier()) ruleRoutine.Stop(reason) } // Our best bet at this point is that we update the metrics with what we hope to schedule in the next tick. @@ -218,14 +218,14 @@ func (sch *schedule) deleteAlertRule(ctx context.Context, keys ...ngmodels.Alert sch.updateRulesMetrics(alertRules) } -func (sch *schedule) getRuleStopReason(ctx context.Context, key ngmodels.AlertRuleKey, rule *ngmodels.AlertRule) error { +func (sch *schedule) getRuleStopReason(ctx context.Context, key ngmodels.AlertRuleKeyWithGroup) error { // If the ruleStopReasonProvider is defined, we will use it to get the reason why the // alert rule was stopped. If it returns an error, we will use the default reason. - if sch.ruleStopReasonProvider == nil || rule == nil { + if sch.ruleStopReasonProvider == nil { return errRuleDeleted } - stopReason, err := sch.ruleStopReasonProvider.FindReason(ctx, sch.log, rule.GetKeyWithGroup()) + stopReason, err := sch.ruleStopReasonProvider.FindReason(ctx, sch.log, key) if err != nil { sch.log.New(key.LogContext()...).Error("Failed to get stop reason", "error", err) return errRuleDeleted diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index 311a419f88b..575f19cda27 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -991,7 +991,7 @@ func TestSchedule_deleteAlertRule(t *testing.T) { require.False(t, sch.registry.exists(key)) }) - t.Run("it should not call ruleStopReasonProvider if the rule is not found in the registry", func(t *testing.T) { + t.Run("it should still call ruleStopReasonProvider if the rule is not found in the registry", func(t *testing.T) { mockReasonProvider := new(mockAlertRuleStopReasonProvider) expectedReason := errors.New("some rule deletion reason") mockReasonProvider.On("FindReason", mock.Anything, mock.Anything, mock.Anything).Return(expectedReason, nil) @@ -1008,9 +1008,9 @@ func TestSchedule_deleteAlertRule(t *testing.T) { sch.deleteAlertRule(ctx, key) - mockReasonProvider.AssertNotCalled(t, "FindReason") + mockReasonProvider.AssertCalled(t, "FindReason", mock.Anything, mock.Anything, rule.GetKeyWithGroup()) - require.ErrorIs(t, info.(*alertRule).ctx.Err(), errRuleDeleted) + require.ErrorIs(t, info.(*alertRule).ctx.Err(), expectedReason) require.False(t, sch.registry.exists(key)) }) }) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index b53b34e05f3..f7a7c3b16f5 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -219,9 +219,10 @@ type Cfg struct { MetricsGrafanaEnvironmentInfo map[string]string // Dashboards - DashboardVersionsToKeep int - MinRefreshInterval string - DefaultHomeDashboardPath string + DashboardVersionsToKeep int + MinRefreshInterval string + DefaultHomeDashboardPath string + DashboardPerformanceMetrics []string // Auth LoginCookieName string @@ -1138,6 +1139,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { cfg.DashboardVersionsToKeep = dashboards.Key("versions_to_keep").MustInt(20) cfg.MinRefreshInterval = valueAsString(dashboards, "min_refresh_interval", "5s") cfg.DefaultHomeDashboardPath = dashboards.Key("default_home_dashboard_path").MustString("") + cfg.DashboardPerformanceMetrics = util.SplitString(dashboards.Key("dashboard_performance_metrics").MustString("")) if err := readUserSettings(iniFile, cfg); err != nil { return err diff --git a/pkg/storage/unified/federated/stats.go b/pkg/storage/unified/federated/stats.go index 98440932ace..a126bcb30fb 100644 --- a/pkg/storage/unified/federated/stats.go +++ b/pkg/storage/unified/federated/stats.go @@ -31,7 +31,17 @@ func (s *LegacyStatsGetter) GetStats(ctx context.Context, in *resource.ResourceS rsp := &resource.ResourceStatsResponse{} err = helper.DB.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - fn := func(table, where, g, r string) error { + fn := func(table, where, g, r string, existCheck bool) error { + // if existCheck is true, do not error out if the table does not exist + if existCheck { + exists, err := sess.IsTableExist(helper.Table(table)) + if !exists { + return nil + } else if err != nil { + return err + } + } + count, err := sess.Table(helper.Table(table)).Where(where, info.OrgID, in.Folder).Count() if err != nil { return err @@ -47,25 +57,25 @@ func (s *LegacyStatsGetter) GetStats(ctx context.Context, in *resource.ResourceS group := "sql-fallback" // Legacy alert rule table - err = fn("alert_rule", "org_id=? AND dashboard_uid=?", group, "alertrules") + err = fn("alert_rule", "org_id=? AND dashboard_uid=?", group, "alertrules", false) if err != nil { return err } // Legacy dashboard table - err = fn("dashboard", "org_id=? AND folder_uid=?", group, "dashboards") + err = fn("dashboard", "org_id=? AND folder_uid=?", group, "dashboards", true) if err != nil { return err } // Legacy folder table - err = fn("folder", "org_id=? AND parent_uid=?", group, "folders") + err = fn("folder", "org_id=? AND parent_uid=?", group, "folders", true) if err != nil { return err } // Legacy library_elements table - err = fn("library_element", "org_id=? AND folder_uid=?", group, "library_elements") + err = fn("library_element", "org_id=? AND folder_uid=?", group, "library_elements", false) if err != nil { return err } diff --git a/pkg/storage/unified/resource/cdk_backend.go b/pkg/storage/unified/resource/cdk_backend.go index ded2d34d7aa..eefba09d78f 100644 --- a/pkg/storage/unified/resource/cdk_backend.go +++ b/pkg/storage/unified/resource/cdk_backend.go @@ -321,6 +321,11 @@ func (c *cdkListIterator) ContinueToken() string { return fmt.Sprintf("index:%d/key:%s", c.index, c.currentKey) } +// ContinueTokenWithCurrentRV implements ListIterator. +func (c *cdkListIterator) ContinueTokenWithCurrentRV() string { + return fmt.Sprintf("index:%d/key:%s", c.index, c.currentKey) +} + // Name implements ListIterator. func (c *cdkListIterator) Name() string { return c.currentKey // TODO (parse name from key) diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index fab1e585486..58d555bd429 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -41,6 +41,9 @@ type ListIterator interface { // The token that can be used to start iterating *after* this item ContinueToken() string + // The token that can be used to start iterating *before* this item + ContinueTokenWithCurrentRV() string + // ResourceVersion of the current item ResourceVersion() int64 @@ -756,9 +759,16 @@ func (s *server) List(ctx context.Context, req *ListRequest) (*ListResponse, err rsp.Items = append(rsp.Items, item) if len(rsp.Items) >= int(req.Limit) || pageBytes >= maxPageBytes { t := iter.ContinueToken() + if req.Source == ListRequest_HISTORY { + // history lists in desc order, so the continue token takes the + // final RV in the list, and then will start from there in the next page, + // rather than the lists first RV + t = iter.ContinueTokenWithCurrentRV() + } if iter.Next() { rsp.NextPageToken = t } + break } } diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index 79a5149efc7..d0c8b0f4ca7 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -655,6 +655,10 @@ func getSortFields(req *resource.ResourceSearchRequest) []string { input = field } + if slices.Contains(DashboardFields(), input) { + input = "fields." + input + } + if sort.Desc { input = "-" + input } diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index effc77bdd57..4535b276c0a 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -529,6 +529,36 @@ func TestBleveBackend(t *testing.T) { }) } +func TestGetSortFields(t *testing.T) { + t.Run("will prepend 'fields.' to sort fields when they are dashboard fields", func(t *testing.T) { + searchReq := &resource.ResourceSearchRequest{ + SortBy: []*resource.ResourceSearchRequest_Sort{ + {Field: "views_total", Desc: false}, + }, + } + sortFields := getSortFields(searchReq) + assert.Equal(t, []string{"fields.views_total"}, sortFields) + }) + t.Run("will prepend sort fields with a '-' when sort is Desc", func(t *testing.T) { + searchReq := &resource.ResourceSearchRequest{ + SortBy: []*resource.ResourceSearchRequest_Sort{ + {Field: "views_total", Desc: true}, + }, + } + sortFields := getSortFields(searchReq) + assert.Equal(t, []string{"-fields.views_total"}, sortFields) + }) + t.Run("will not prepend 'fields.' to common fields", func(t *testing.T) { + searchReq := &resource.ResourceSearchRequest{ + SortBy: []*resource.ResourceSearchRequest_Sort{ + {Field: "description", Desc: false}, + }, + } + sortFields := getSortFields(searchReq) + assert.Equal(t, []string{"description"}, sortFields) + }) +} + func asTimePointer(milli int64) *time.Time { if milli > 0 { t := time.UnixMilli(milli) diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index d56cd87b2e1..0401c77a25c 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -519,6 +519,10 @@ func (l *listIter) ContinueToken() string { return ContinueToken{ResourceVersion: l.listRV, StartOffset: l.offset}.String() } +func (l *listIter) ContinueTokenWithCurrentRV() string { + return ContinueToken{ResourceVersion: l.rv, StartOffset: l.offset}.String() +} + func (l *listIter) Error() error { return l.err } diff --git a/pkg/storage/unified/sql/data/resource_history_get.sql b/pkg/storage/unified/sql/data/resource_history_get.sql index cbb2786e534..5ab25eb2e0c 100644 --- a/pkg/storage/unified/sql/data/resource_history_get.sql +++ b/pkg/storage/unified/sql/data/resource_history_get.sql @@ -16,6 +16,6 @@ WHERE 1 = 1 AND {{ .Ident "action" }} = 3 {{ end }} {{ if (gt .StartRV 0) }} - AND {{ .Ident "resource_version" }} > {{ .Arg .StartRV }} + AND {{ .Ident "resource_version" }} < {{ .Arg .StartRV }} {{ end }} ORDER BY resource_version DESC diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index 5883610bd54..cc5cacc524b 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -351,6 +351,83 @@ func TestIntegrationBackendList(t *testing.T) { require.Equal(t, rv8, continueToken.ResourceVersion) require.Equal(t, int64(4), continueToken.StartOffset) }) + + // add 5 events for item1 - should be saved to history + rvHistory1, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) + require.NoError(t, err) + require.Greater(t, rvHistory1, rv1) + rvHistory2, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) + require.NoError(t, err) + require.Greater(t, rvHistory2, rvHistory1) + rvHistory3, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) + require.NoError(t, err) + require.Greater(t, rvHistory3, rvHistory2) + rvHistory4, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) + require.NoError(t, err) + require.Greater(t, rvHistory4, rvHistory3) + rvHistory5, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED) + require.NoError(t, err) + require.Greater(t, rvHistory5, rvHistory4) + + t.Run("fetch first history page at revision with limit", func(t *testing.T) { + res, err := server.List(ctx, &resource.ListRequest{ + Limit: 3, + Source: resource.ListRequest_HISTORY, + Options: &resource.ListOptions{ + Key: &resource.ResourceKey{ + Namespace: "namespace", + Group: "group", + Resource: "resource", + Name: "item1", + }, + }, + }) + require.NoError(t, err) + require.NoError(t, err) + require.Nil(t, res.Error) + require.Len(t, res.Items, 3) + t.Log(res.Items) + // should be in desc order, so the newest RVs are returned first + require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value)) + require.Equal(t, rvHistory5, res.Items[0].ResourceVersion) + require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value)) + require.Equal(t, rvHistory4, res.Items[1].ResourceVersion) + require.Equal(t, "item1 MODIFIED", string(res.Items[2].Value)) + require.Equal(t, rvHistory3, res.Items[2].ResourceVersion) + + continueToken, err := sql.GetContinueToken(res.NextPageToken) + require.NoError(t, err) + // should return the furthest back RV as the next page token + require.Equal(t, rvHistory3, continueToken.ResourceVersion) + }) + + t.Run("fetch second page of history at revision", func(t *testing.T) { + continueToken := &sql.ContinueToken{ + ResourceVersion: rvHistory3, + StartOffset: 2, + } + res, err := server.List(ctx, &resource.ListRequest{ + NextPageToken: continueToken.String(), + Limit: 2, + Source: resource.ListRequest_HISTORY, + Options: &resource.ListOptions{ + Key: &resource.ResourceKey{ + Namespace: "namespace", + Group: "group", + Resource: "resource", + Name: "item1", + }, + }, + }) + require.NoError(t, err) + require.Nil(t, res.Error) + require.Len(t, res.Items, 2) + t.Log(res.Items) + require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value)) + require.Equal(t, rvHistory2, res.Items[0].ResourceVersion) + require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value)) + require.Equal(t, rvHistory1, res.Items[1].ResourceVersion) + }) } func TestIntegrationBlobSupport(t *testing.T) { diff --git a/pkg/storage/unified/sql/testdata/mysql--resource_history_get-read trash second page.sql b/pkg/storage/unified/sql/testdata/mysql--resource_history_get-read trash second page.sql index 16359886766..cd026c1dfef 100755 --- a/pkg/storage/unified/sql/testdata/mysql--resource_history_get-read trash second page.sql +++ b/pkg/storage/unified/sql/testdata/mysql--resource_history_get-read trash second page.sql @@ -10,5 +10,5 @@ WHERE 1 = 1 AND `group` = 'gg' AND `resource` = 'rr' AND `action` = 3 - AND `resource_version` > 123456 + AND `resource_version` < 123456 ORDER BY resource_version DESC diff --git a/pkg/storage/unified/sql/testdata/postgres--resource_history_get-read trash second page.sql b/pkg/storage/unified/sql/testdata/postgres--resource_history_get-read trash second page.sql index 0bf9c65a25a..b5df5de6315 100755 --- a/pkg/storage/unified/sql/testdata/postgres--resource_history_get-read trash second page.sql +++ b/pkg/storage/unified/sql/testdata/postgres--resource_history_get-read trash second page.sql @@ -10,5 +10,5 @@ WHERE 1 = 1 AND "group" = 'gg' AND "resource" = 'rr' AND "action" = 3 - AND "resource_version" > 123456 + AND "resource_version" < 123456 ORDER BY resource_version DESC diff --git a/pkg/storage/unified/sql/testdata/sqlite--resource_history_get-read trash second page.sql b/pkg/storage/unified/sql/testdata/sqlite--resource_history_get-read trash second page.sql index 0bf9c65a25a..b5df5de6315 100755 --- a/pkg/storage/unified/sql/testdata/sqlite--resource_history_get-read trash second page.sql +++ b/pkg/storage/unified/sql/testdata/sqlite--resource_history_get-read trash second page.sql @@ -10,5 +10,5 @@ WHERE 1 = 1 AND "group" = 'gg' AND "resource" = 'rr' AND "action" = 3 - AND "resource_version" > 123456 + AND "resource_version" < 123456 ORDER BY resource_version DESC diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index 462e38863a5..a24bd4b027b 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -120,6 +120,10 @@ export class SharedPreferences extends PureComponent { onThemeChanged = (value: ComboboxOption) => { this.setState({ theme: value.value }); + reportInteraction('grafana_preferences_theme_changed', { + toTheme: value.value, + preferenceType: this.props.preferenceType, + }); if (value.value) { changeTheme(value.value, true); diff --git a/public/app/core/components/TimePicker/TimePickerWithHistory.tsx b/public/app/core/components/TimePicker/TimePickerWithHistory.tsx index 64c08d31b67..db4ac5ac21d 100644 --- a/public/app/core/components/TimePicker/TimePickerWithHistory.tsx +++ b/public/app/core/components/TimePicker/TimePickerWithHistory.tsx @@ -2,8 +2,8 @@ import { uniqBy } from 'lodash'; import { AppEvents, TimeRange, isDateTime, rangeUtil } from '@grafana/data'; import { TimeRangePickerProps, TimeRangePicker } from '@grafana/ui'; -import { t } from '@grafana/ui/src/utils/i18n'; import appEvents from 'app/core/app_events'; +import { t } from 'app/core/internationalization'; import { LocalStorageValueProvider } from '../LocalStorageValueProvider'; diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 839d5db06c4..490d264c5fa 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -214,7 +214,7 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { pageClass: 'page-alerting', roles: evaluateAccess([AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite]), component: importAlertingComponent( - () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') + () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/rule-editor/RuleEditor') ), }, { @@ -222,7 +222,7 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { pageClass: 'page-alerting', roles: evaluateAccess([AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleExternalWrite]), component: importAlertingComponent( - () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') + () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/rule-editor/RuleEditor') ), }, { diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index f483df956e7..806b782a2bf 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -248,6 +248,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({ const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); return { url: path, params }; }, + providesTags: (_result, _error, { namespace }) => [{ type: 'RuleNamespace', id: namespace }], }), // TODO This should be probably a separate ruler API file diff --git a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx index 108532e5e41..5aca1d77824 100644 --- a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx +++ b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx @@ -7,8 +7,8 @@ import { Alert, LoadingPlaceholder } from '@grafana/ui'; import { RuleIdentifier } from '../../../../../types/unified-alerting'; import { useRuleWithLocation } from '../../hooks/useCombinedRule'; +import { formValuesFromExistingRule } from '../../rule-editor/formDefaults'; import { stringifyErrorLike } from '../../utils/misc'; -import { formValuesFromExistingRule } from '../../utils/rule-form'; import * as ruleId from '../../utils/rule-id'; import { isGrafanaRulerRule } from '../../utils/rules'; import { createRelativeUrl } from '../../utils/url'; diff --git a/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.test.tsx b/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.test.tsx index 3b76b141690..c82097c0df0 100644 --- a/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.test.tsx +++ b/public/app/features/alerting/unified/components/expressions/ExpressionStatusIndicator.test.tsx @@ -1,8 +1,8 @@ import { render, screen } from '@testing-library/react'; import { FormProvider, useForm } from 'react-hook-form'; +import { getDefaultFormValues } from '../../rule-editor/formDefaults'; import { RuleFormValues } from '../../types/rule-form'; -import { getDefaultFormValues } from '../../utils/rule-form'; import { ExpressionStatusIndicator } from './ExpressionStatusIndicator'; diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx index df1ee304e11..9f1067761ca 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx @@ -6,9 +6,9 @@ import { byRole, byTestId } from 'testing-library-selector'; import { DashboardSearchItemType } from '../../../../search/types'; import { mockDashboardApi, setupMswServer } from '../../mockApi'; import { mockDashboardDto, mockDashboardSearchItem } from '../../mocks'; +import { getDefaultFormValues } from '../../rule-editor/formDefaults'; import { RuleFormValues } from '../../types/rule-form'; import { Annotation } from '../../utils/constants'; -import { getDefaultFormValues } from '../../utils/rule-form'; import AnnotationsStep from './AnnotationsStep'; diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index 3a918eb6369..6104c3b2fa6 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -1,12 +1,11 @@ import { css } from '@emotion/css'; -import { debounce, take, uniqueId } from 'lodash'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { uniqueId } from 'lodash'; +import { useEffect, useMemo, useState } from 'react'; import { Controller, FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { - AsyncSelect, Box, Button, Field, @@ -15,6 +14,7 @@ import { Input, Label, Modal, + Select, Stack, Switch, Text, @@ -22,17 +22,13 @@ import { useStyles2, } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; -import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; -import { LogMessages, logInfo } from '../../Analytics'; import { alertRuleApi } from '../../api/alertRuleApi'; import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi'; -import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; -import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults'; import { RuleFormValues } from '../../types/rule-form'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; -import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form'; import { isGrafanaAlertingRuleByType, isGrafanaManagedRuleByType, @@ -53,48 +49,38 @@ import { RuleEditorSection } from './RuleEditorSection'; export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds export const MAX_GROUP_RESULTS = 1000; -export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups: boolean) => { +const useFetchGroupsForFolder = (folderUid: string) => { // fetch the ruler rules from the database so we can figure out what other "groups" are already defined // for our folders - const { isLoading: isLoadingRulerNamespace, currentData: rulerNamespace } = - alertRuleApi.endpoints.rulerNamespace.useQuery( - { - namespace: folderUid, - rulerConfig: GRAFANA_RULER_CONFIG, - }, - { - skip: !folderUid, - refetchOnMountOrArgChange: true, - } - ); - - // There should be only one entry in the rulerNamespace object - // However it uses folder name as key, so to avoid fetching folder name, we use Object.values - const groupOptions = useMemo(() => { - if (!rulerNamespace) { - // still waiting for namespace information to be fetched - return []; + return alertRuleApi.endpoints.rulerNamespace.useQuery( + { + namespace: folderUid, + rulerConfig: GRAFANA_RULER_CONFIG, + }, + { + refetchOnMountOrArgChange: true, + skip: !folderUid, } + ); +}; - const folderGroups = Object.values(rulerNamespace).flat() ?? []; +const namespaceToGroupOptions = (rulerNamespace: RulerRulesConfigDTO, enableProvisionedGroups: boolean) => { + const folderGroups = Object.values(rulerNamespace).flat(); - return folderGroups - .map>((group) => { - const isProvisioned = isProvisionedGroup(group); - return { - label: group.name, - value: group.name, - description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, - // we include provisioned folders, but disable the option to select them - isDisabled: !enableProvisionedGroups ? isProvisioned : false, - isProvisioned: isProvisioned, - }; - }) + return folderGroups + .map>((group) => { + const isProvisioned = isProvisionedGroup(group); + return { + label: group.name, + value: group.name, + description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, + // we include provisioned folders, but disable the option to select them + isDisabled: !enableProvisionedGroups ? isProvisioned : false, + isProvisioned: isProvisioned, + }; + }) - .sort(sortByLabel); - }, [rulerNamespace, enableProvisionedGroups]); - - return { groupOptions, loading: isLoadingRulerNamespace }; + .sort(sortByLabel); }; const isProvisionedGroup = (group: RulerRuleGroupDTO) => { @@ -105,10 +91,6 @@ const sortByLabel = (a: SelectableValue, b: SelectableValue) => return a.label?.localeCompare(b.label ?? '') || 0; }; -const findGroupMatchingLabel = (group: SelectableValue, query: string) => { - return group.label?.toLowerCase().includes(query.toLowerCase()); -}; - const forValidationOptions = (evaluateEvery: string): RegisterOptions<{ evaluateFor: string }> => ({ required: { value: true, @@ -149,24 +131,10 @@ const forValidationOptions = (evaluateEvery: string): RegisterOptions<{ evaluate }, }); -const useIsNewGroup = (folder: string, group: string) => { - const { groupOptions } = useFolderGroupOptions(folder, false); - - const groupIsInGroupOptions = useCallback( - (group_: string) => groupOptions.some((groupInList: SelectableValue) => groupInList.label === group_), - [groupOptions] - ); - return !groupIsInGroupOptions(group); -}; - export function GrafanaEvaluationBehaviorStep({ - evaluateEvery, - setEvaluateEvery, existing, enableProvisionedGroups, }: { - evaluateEvery: string; - setEvaluateEvery: (value: string) => void; existing: boolean; enableProvisionedGroups: boolean; }) { @@ -181,51 +149,39 @@ export function GrafanaEvaluationBehaviorStep({ control, } = useFormContext(); - const [folder, group, type, isPaused, folderUid, folderName] = watch([ - 'folder', + const [group, type, isPaused, folder, evaluateEvery] = watch([ 'group', 'type', 'isPaused', - 'folder.uid', - 'folder.title', + 'folder', + 'evaluateEvery', ]); const isGrafanaAlertingRule = isGrafanaAlertingRuleByType(type); const isGrafanaRecordingRule = isGrafanaRecordingRuleByType(type); - const { groupOptions, loading } = useFolderGroupOptions(folder?.uid ?? '', enableProvisionedGroups); + const { currentData: rulerNamespace, isLoading: loadingGroups } = useFetchGroupsForFolder(folder?.uid ?? ''); const [isEditingGroup, setIsEditingGroup] = useState(false); - const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); - const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; + const groupOptions = useMemo(() => { + return rulerNamespace ? namespaceToGroupOptions(rulerNamespace, enableProvisionedGroups) : []; + }, [enableProvisionedGroups, rulerNamespace]); - const grafanaNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME); - const existingNamespace = grafanaNamespaces.find((ns) => ns.uid === folderUid); - const existingGroup = existingNamespace?.groups.find((g) => g.name === group); - - const isNewGroup = useIsNewGroup(folderUid ?? '', group); + const existingGroup = Object.values(rulerNamespace ?? {}) + .flat() + .find((ruleGroup) => ruleGroup.name === group); + const isNewGroup = !existingGroup && !loadingGroups; + // synchronize the evaluation interval with the group name when it's an existing group useEffect(() => { - if (!isNewGroup && existingGroup?.interval) { - setEvaluateEvery(existingGroup.interval); + if (existingGroup) { + setValue('evaluateEvery', existingGroup.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL); } - }, [setEvaluateEvery, isNewGroup, setValue, existingGroup]); - - const closeEditGroupModal = (saved = false) => { - if (!saved) { - logInfo(LogMessages.leavingRuleGroupEdit); - } - setIsEditingGroup(false); - }; + }, [existingGroup, setValue]); + const closeEditGroupModal = () => setIsEditingGroup(false); const onOpenEditGroupModal = () => setIsEditingGroup(true); - const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderUid || !group; - const emptyNamespace: CombinedRuleNamespace = { - name: folderName, - rulesSource: GRAFANA_RULES_SOURCE_NAME, - groups: [], - }; - const emptyGroup: CombinedRuleGroup = { name: group, interval: evaluateEvery, rules: [], totals: {} }; + const editGroupDisabled = loadingGroups || isNewGroup || !folder?.uid || !group; const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false); @@ -235,18 +191,6 @@ export function GrafanaEvaluationBehaviorStep({ setIsCreatingEvaluationGroup(false); }; - const getOptions = useCallback( - async (query: string) => { - const results = query ? groupOptions.filter((group) => findGroupMatchingLabel(group, query)) : groupOptions; - return take(results, MAX_GROUP_RESULTS); - }, - [groupOptions] - ); - - const debouncedSearch = useMemo(() => { - return debounce(getOptions, 300, { leading: true }); - }, [getOptions]); - const defaultGroupValue = group ? { value: group, label: group } : undefined; const pauseContentText = isGrafanaRecordingRule @@ -257,7 +201,7 @@ export function GrafanaEvaluationBehaviorStep({ const step = isGrafanaManagedRuleByType(type) ? 4 : 3; const label = - isGrafanaManagedRuleByType(type) && !folder + isGrafanaManagedRuleByType(type) && !folder?.uid ? t( 'alerting.rule-form.evaluation.select-folder-before', 'Select a folder before setting evaluation group and interval' @@ -284,21 +228,20 @@ export function GrafanaEvaluationBehaviorStep({ > ( - { field.onChange(group.label ?? ''); }} - isLoading={loading} - invalid={Boolean(folder) && !group && Boolean(fieldState.error)} - loadOptions={debouncedSearch} + isLoading={loadingGroups} + invalid={Boolean(folder?.uid) && !group && Boolean(fieldState.error)} cacheOptions loadingMessage={'Loading groups...'} defaultValue={defaultGroupValue} - defaultOptions={groupOptions} + options={groupOptions} getOptionLabel={(option: SelectableValue) => (
{option.label} @@ -329,7 +272,7 @@ export function GrafanaEvaluationBehaviorStep({ icon="plus" fill="outline" variant="secondary" - disabled={!folder} + disabled={!folder?.uid} data-testid={selectors.components.AlertRules.newEvaluationGroupButton} > New evaluation group @@ -339,22 +282,25 @@ export function GrafanaEvaluationBehaviorStep({ setIsCreatingEvaluationGroup(false)} - groupfoldersForGrafana={groupfoldersForGrafana?.result} + groupfoldersForGrafana={rulerNamespace} /> )} - {folderName && isEditingGroup && ( + {folder?.uid && isEditingGroup && ( closeEditGroupModal()} intervalEditOnly hideFolder={true} /> )} - {folderName && group && ( + {folder?.title && group && (
diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 54d22db90ac..3d46e0873e7 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { FormProvider, SubmitErrorHandler, UseFormWatch, useForm } from 'react-hook-form'; import { useParams } from 'react-router-dom-v5-compat'; @@ -22,7 +22,6 @@ import { isGrafanaRulerRulePaused, isRecordingRuleByType, } from 'app/features/alerting/unified/utils/rules'; -import { isExpressionQuery } from 'app/features/expressions/guards'; import { RuleGroupIdentifier, RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting'; import { PostableRuleGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; @@ -40,24 +39,21 @@ import { shouldUsePrometheusRulesPrimary } from '../../../featureToggles'; import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup'; import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup'; import { useReturnTo } from '../../../hooks/useReturnTo'; -import { useURLSearchParams } from '../../../hooks/useURLSearchParams'; -import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; -import { DataSourceType } from '../../../utils/datasource'; import { - DEFAULT_GROUP_EVALUATION_INTERVAL, + defaultFormValuesForRuleType, + formValuesFromExistingRule, + formValuesFromPrefill, + translateRouteParamToRuleType, +} from '../../../rule-editor/formDefaults'; +import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; +import { MANUAL_ROUTING_KEY, SIMPLIFIED_QUERY_EDITOR_KEY, - formValuesFromExistingRule, formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO, - getDefaultFormValues, - getDefaultQueries, - ignoreHiddenQueries, - normalizeDefaultAnnotations, } from '../../../utils/rule-form'; import * as ruleId from '../../../utils/rule-id'; import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier, stringifyIdentifier } from '../../../utils/rule-id'; -import { isGrafanaRecordingRuleByType } from '../../../utils/rules'; import { createRelativeUrl } from '../../../utils/url'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; @@ -68,12 +64,7 @@ import { GrafanaFolderAndLabelsStep } from '../GrafanaFolderAndLabelsStep'; import { NotificationsStep } from '../NotificationsStep'; import { RecordingRulesNameSpaceAndGroupStep } from '../RecordingRulesNameSpaceAndGroupStep'; import { RuleInspector } from '../RuleInspector'; -import { - QueryAndExpressionsStep, - areQueriesTransformableToSimpleCondition, - isExpressionQueryInAlert, -} from '../query-and-alert-condition/QueryAndExpressionsStep'; -import { translateRouteParamToRuleType } from '../util'; +import { QueryAndExpressionsStep } from '../query-and-alert-condition/QueryAndExpressionsStep'; type Props = { existing?: RuleWithLocation; @@ -85,9 +76,7 @@ const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); export const AlertRuleForm = ({ existing, prefill }: Props) => { const styles = useStyles2(getStyles); const notifyApp = useAppNotification(); - const [queryParams] = useURLSearchParams(); const [showEditYaml, setShowEditYaml] = useState(false); - const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL); const [deleteRuleFromGroup] = useDeleteRuleFromGroup(); const [addRuleToRuleGroup] = useAddRuleToRuleGroup(); @@ -110,19 +99,10 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { return formValuesFromPrefill(prefill); } - if (queryParams.has('defaults')) { - return formValuesFromQueryParams(queryParams.get('defaults') ?? '', ruleType); - } const defaultRuleType = ruleType || RuleFormType.grafana; - return { - ...getDefaultFormValues(), - condition: 'C', - queries: getDefaultQueries(isGrafanaRecordingRuleByType(defaultRuleType)), - type: defaultRuleType, - evaluateEvery: evaluateEvery, - }; - }, [existing, prefill, queryParams, evaluateEvery, ruleType]); + return defaultFormValuesForRuleType(defaultRuleType); + }, [existing, prefill, ruleType]); const formAPI = useForm({ mode: 'onSubmit', @@ -151,6 +131,8 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { // @todo why is error not propagated to form? const submit = async (values: RuleFormValues, exitOnSave: boolean) => { + const { type, evaluateEvery } = values; + if (conditionErrorMsg !== '') { notifyApp.error(conditionErrorMsg); if (!existing && grafanaTypeRule) { @@ -160,7 +142,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { return; } - trackAlertRuleFormSaved({ formAction: existing ? 'update' : 'create', ruleType: values.type }); + trackAlertRuleFormSaved({ formAction: existing ? 'update' : 'create', ruleType: type }); const ruleDefinition = grafanaTypeRule ? formValuesToRulerGrafanaRuleDTO(values) : formValuesToRulerRuleDTO(values); @@ -206,12 +188,11 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { const deleteRule = async () => { if (existing) { - const returnTo = queryParams.get('returnTo') || '/alerting/list'; const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(existing); const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule); await deleteRuleFromGroup.execute(ruleGroupIdentifier, ruleIdentifier); - locationService.replace(returnTo); + locationService.replace(returnTo ?? '/alerting/list'); } }; @@ -236,9 +217,6 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { locationService.getHistory().goBack(); }; - const evaluateEveryInForm = watch('evaluateEvery'); - useEffect(() => setEvaluateEvery(evaluateEveryInForm), [evaluateEveryInForm]); - const actionButtons = ( {existing && ( @@ -314,12 +292,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { {/* Step 4 & 5 & 6*/} {isGrafanaManagedRuleByType(type) && ( - + )} {/* Notifications step*/} @@ -379,101 +352,17 @@ const isCortexLokiOrRecordingRule = (watch: UseFormWatch) => { return (ruleType === RuleFormType.cloudAlerting || ruleType === RuleFormType.cloudRecording) && dataSourceName !== ''; }; -function formValuesFromQueryParams(ruleDefinition: string, type: RuleFormType): RuleFormValues { - let ruleFromQueryParams: Partial; - - try { - ruleFromQueryParams = JSON.parse(ruleDefinition); - } catch (err) { - return { - ...getDefaultFormValues(), - queries: getDefaultQueries(), - }; - } - - return setQueryEditorSettings( - setInstantOrRange( - ignoreHiddenQueries({ - ...getDefaultFormValues(), - ...ruleFromQueryParams, - annotations: normalizeDefaultAnnotations(ruleFromQueryParams.annotations ?? []), - queries: ruleFromQueryParams.queries ?? getDefaultQueries(), - type: type || RuleFormType.grafana, - evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL, - }) - ) - ); -} - -function formValuesFromPrefill(rule: Partial): RuleFormValues { - return ignoreHiddenQueries({ - ...getDefaultFormValues(), - ...rule, - }); -} - -function setQueryEditorSettings(values: RuleFormValues): RuleFormValues { - const isQuerySwitchModeEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false; - - if (!isQuerySwitchModeEnabled) { - return { - ...values, - editorSettings: { - simplifiedQueryEditor: false, - simplifiedNotificationEditor: true, // actually it doesn't matter in this case - }, - }; - } - - // data queries only - const dataQueries = values.queries.filter((query) => !isExpressionQuery(query.model)); - - // expression queries only - const expressionQueries = values.queries.filter((query) => isExpressionQueryInAlert(query)); - - const queryParamsAreTransformable = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries); - return { - ...values, - editorSettings: { - simplifiedQueryEditor: queryParamsAreTransformable, - simplifiedNotificationEditor: true, - }, - }; -} - -function setInstantOrRange(values: RuleFormValues): RuleFormValues { - return { - ...values, - queries: values.queries?.map((query) => { - if (isExpressionQuery(query.model)) { - return query; - } - // data query - const defaultToInstant = - query.model.datasource?.type === DataSourceType.Loki || - query.model.datasource?.type === DataSourceType.Prometheus; - const isInstant = - 'instant' in query.model && query.model.instant !== undefined ? query.model.instant : defaultToInstant; - return { - ...query, - model: { - ...query.model, - instant: isInstant, - range: !isInstant, // we cannot have both instant and range queries in alerting - }, - }; - }), - }; -} - function storeInLocalStorageValues(values: RuleFormValues) { - if (values.manualRouting) { + const { manualRouting, editorSettings } = values; + + if (manualRouting) { localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); } else { localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); } - if (values.editorSettings) { - if (values.editorSettings.simplifiedQueryEditor) { + + if (editorSettings) { + if (editorSettings.simplifiedQueryEditor) { localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'true'); } else { localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'false'); diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx index 38598f08784..72093ecc490 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx @@ -16,14 +16,10 @@ import { alertRuleApi } from '../../../api/alertRuleApi'; import { fetchRulerRulesGroup } from '../../../api/ruler'; import { useDataSourceFeatures } from '../../../hooks/useCombinedRule'; import { useReturnTo } from '../../../hooks/useReturnTo'; +import { DEFAULT_GROUP_EVALUATION_INTERVAL, getDefaultFormValues } from '../../../rule-editor/formDefaults'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; -import { - DEFAULT_GROUP_EVALUATION_INTERVAL, - formValuesToRulerGrafanaRuleDTO, - getDefaultFormValues, - getDefaultQueries, -} from '../../../utils/rule-form'; +import { formValuesToRulerGrafanaRuleDTO, getDefaultQueries } from '../../../utils/rule-form'; import { isGrafanaRulerRule } from '../../../utils/rules'; import { FileExportPreview } from '../../export/FileExportPreview'; import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer'; @@ -64,9 +60,7 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor const { returnTo } = useReturnTo('/alerting/list'); const [exportData, setExportData] = useState(undefined); - const [conditionErrorMsg, setConditionErrorMsg] = useState(''); - const [evaluateEvery, setEvaluateEvery] = useState(ruleForm?.evaluateEvery ?? DEFAULT_GROUP_EVALUATION_INTERVAL); const onInvalid = (): void => { notifyApp.error('There are errors in the form. Please correct them and try again!'); @@ -112,12 +106,7 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor {/* Step 4 & 5 */} - + {/* Notifications step*/} {/* Annotations only for cloud and Grafana */} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/getPayloadToExport.test.ts b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/getPayloadToExport.test.ts index 6fc5ba37a35..2a1e67855e3 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/getPayloadToExport.test.ts +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/getPayloadToExport.test.ts @@ -1,9 +1,9 @@ import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { mockRulerGrafanaRecordingRule, mockRulerGrafanaRule } from '../../../mocks'; +import { getDefaultFormValues } from '../../../rule-editor/formDefaults'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { Annotation } from '../../../utils/constants'; -import { getDefaultFormValues } from '../../../utils/rule-form'; import { getPayloadToExport } from './ModifyExportRuleForm'; diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx index 11b3a0f5dfc..116374475da 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx @@ -106,8 +106,10 @@ describe('Can create a new grafana managed alert using simplified routing', () = it('simplified routing is not available when Grafana AM is not enabled', async () => { setAlertmanagerChoices(AlertmanagerChoice.External, 1); - renderRuleEditor(); + const { user } = renderRuleEditor(); + // Just to make sure all dropdowns have been loaded + await selectFolderAndGroup(user); await waitFor(() => expect(ui.inputs.simplifiedRouting.contactPointRouting.query()).not.toBeInTheDocument()); }); @@ -147,6 +149,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = expect(await screen.findByText('Email')).toBeInTheDocument(); }); }); + describe('switch modes enabled', () => { testWithFeatureToggles(['alertingQueryAndExpressionsStepMode', 'alertingNotificationsStepMode']); @@ -168,6 +171,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = const serializedRequests = await serializeRequests(requests); expect(serializedRequests).toMatchSnapshot(); }); + it('can create the new grafana-managed rule with advanced modes', async () => { const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); @@ -185,6 +189,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = const serializedRequests = await serializeRequests(requests); expect(serializedRequests).toMatchSnapshot(); }); + it('can create the new grafana-managed rule with only notifications step advanced mode', async () => { const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); @@ -202,6 +207,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = const serializedRequests = await serializeRequests(requests); expect(serializedRequests).toMatchSnapshot(); }); + it('can create the new grafana-managed rule with only query step advanced mode', async () => { const contactPointName = 'lotsa-emails'; const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); @@ -221,6 +227,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = const serializedRequests = await serializeRequests(requests); expect(serializedRequests).toMatchSnapshot(); }); + it('switch modes are intiallized depending on the local storage - 1', async () => { localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'false'); localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); @@ -231,6 +238,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = expect(ui.inputs.switchModeAdvanced(GrafanaRuleFormStep.Query).get()).toBeInTheDocument(); expect(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Notification).get()).toBeInTheDocument(); }); + it('switch modes are intiallized depending on the local storage - 2', async () => { localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'true'); localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.test.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.test.tsx index 752d9e71101..a0983cba07f 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.test.tsx @@ -136,6 +136,7 @@ describe('LabelsField with suggestions', () => { expect(screen.getByTestId('labelsInSubform-key-2')).toHaveTextContent('key3'); expect(screen.getByTestId('labelsInSubform-value-2')).toHaveTextContent('value3'); }); + it('Should be able to write new keys and values using the dropdowns, case sensitive', async () => { const { user } = await renderLabelsWithSuggestions(); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index f43e3a14b67..2ecd8131b3b 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -23,16 +23,14 @@ import { import { Text } from '@grafana/ui/src/components/Text/Text'; import { Trans, t } from 'app/core/internationalization'; import { isExpressionQuery } from 'app/features/expressions/guards'; -import { - ExpressionDatasourceUID, - ExpressionQuery, - ExpressionQueryType, - ReducerMode, - expressionTypes, -} from 'app/features/expressions/types'; -import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; +import { ExpressionDatasourceUID, ExpressionQueryType, expressionTypes } from 'app/features/expressions/types'; +import { AlertQuery } from 'app/types/unified-alerting-dto'; import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler'; +import { + areQueriesTransformableToSimpleCondition, + isExpressionQueryInAlert, +} from '../../../rule-editor/formProcessing'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource'; import { PromOrLokiQuery, isPromOrLokiQuery } from '../../../utils/rule-form'; @@ -76,49 +74,6 @@ import { import { useAdvancedMode } from './useAdvancedMode'; import { useAlertQueryRunner } from './useAlertQueryRunner'; -export function areQueriesTransformableToSimpleCondition( - dataQueries: Array>, - expressionQueries: Array> -) { - if (dataQueries.length !== 1) { - return false; - } - const singleReduceExpressionInInstantQuery = - 'instant' in dataQueries[0].model && dataQueries[0].model.instant && expressionQueries.length === 1; - - if (expressionQueries.length !== 2 && !singleReduceExpressionInInstantQuery) { - return false; - } - - const query = dataQueries[0]; - - if (query.refId !== SimpleConditionIdentifier.queryId) { - return false; - } - - const reduceExpressionIndex = expressionQueries.findIndex( - (query) => query.model.type === ExpressionQueryType.reduce && query.refId === SimpleConditionIdentifier.reducerId - ); - const reduceExpression = expressionQueries.at(reduceExpressionIndex); - const reduceOk = - reduceExpression && - reduceExpressionIndex === 0 && - (reduceExpression.model.settings?.mode === ReducerMode.Strict || - reduceExpression.model.settings?.mode === undefined); - - const thresholdExpressionIndex = expressionQueries.findIndex( - (query) => - query.model.type === ExpressionQueryType.threshold && query.refId === SimpleConditionIdentifier.thresholdId - ); - const thresholdExpression = expressionQueries.at(thresholdExpressionIndex); - const conditions = thresholdExpression?.model.conditions ?? []; - const thresholdIndexOk = singleReduceExpressionInInstantQuery - ? thresholdExpressionIndex === 0 - : thresholdExpressionIndex === 1; - const thresholdOk = thresholdExpression && thresholdIndexOk && conditions[0]?.unloadEvaluator === undefined; - return (Boolean(reduceOk) || Boolean(singleReduceExpressionInInstantQuery)) && Boolean(thresholdOk); -} - interface Props { editingExistingRule: boolean; onDataChange: (error: string) => void; @@ -777,9 +732,3 @@ const useSetExpressionAndDataSource = () => { } }; }; - -export function isExpressionQueryInAlert( - query: AlertQuery -): query is AlertQuery { - return isExpressionQuery(query.model); -} diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts index 208342735e6..2a9294ada7d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts @@ -2,11 +2,10 @@ import { produce } from 'immer'; import { EvalFunction } from 'app/features/alerting/state/alertDef'; import { dataQuery, reduceExpression, thresholdExpression } from 'app/features/alerting/unified/mocks'; +import { areQueriesTransformableToSimpleCondition } from 'app/features/alerting/unified/rule-editor/formProcessing'; import { ExpressionQuery, ReducerMode } from 'app/features/expressions/types'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; -import { areQueriesTransformableToSimpleCondition } from '../QueryAndExpressionsStep'; - const expressionQueries: Array> = [reduceExpression, thresholdExpression]; describe('areQueriesTransformableToSimpleCondition', () => { diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/useAdvancedMode.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/useAdvancedMode.ts index 0c793dfdaac..08e9d889bbf 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/useAdvancedMode.ts +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/useAdvancedMode.ts @@ -5,7 +5,8 @@ import { EvalFunction } from 'app/features/alerting/state/alertDef'; import { ExpressionQuery } from 'app/features/expressions/types'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; -import { areQueriesTransformableToSimpleCondition } from './QueryAndExpressionsStep'; +import { areQueriesTransformableToSimpleCondition } from '../../../rule-editor/formProcessing'; + import { SimpleCondition, getSimpleConditionFromExpressions } from './SimpleCondition'; function initializeSimpleCondition( @@ -30,7 +31,7 @@ export function determineAdvancedMode(simplifiedQueryEditor: boolean | undefined } /* - This hook is used mantain the state of the advanced mode, and the simple condition, + This hook is used mantain the state of the advanced mode, and the simple condition, depending on the editor settings, the alert type, and the queries. */ export const useAdvancedMode = ( diff --git a/public/app/features/alerting/unified/components/rule-editor/util.ts b/public/app/features/alerting/unified/components/rule-editor/util.ts index 1c432344b1c..3933de4f48e 100644 --- a/public/app/features/alerting/unified/components/rule-editor/util.ts +++ b/public/app/features/alerting/unified/components/rule-editor/util.ts @@ -15,8 +15,6 @@ import { isExpressionQuery } from 'app/features/expressions/guards'; import { ClassicCondition, ExpressionQueryType } from 'app/features/expressions/types'; import { AlertQuery } from 'app/types/unified-alerting-dto'; -import { RuleFormType } from '../../types/rule-form'; - import { createDagFromQueries, getOriginOfRefId } from './dag'; export function queriesWithUpdatedReferences( @@ -312,18 +310,6 @@ export function getStatusMessage(data: PanelData): string | undefined { return data.error?.message ?? genericErrorMessage; } -export function translateRouteParamToRuleType(param = ''): RuleFormType { - if (param === 'recording') { - return RuleFormType.cloudRecording; - } - - if (param === 'grafana-recording') { - return RuleFormType.grafanaRecording; - } - - return RuleFormType.grafana; -} - /** * This function finds what refIds have been updated given the previous Array of queries and an Array of updated data queries. * All expression queries are discarded from the arrays, since we have separate handlers for those (see "onUpdateRefId") of the ExpressionEditor diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx index be7153bc131..7f41a873797 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx @@ -4,7 +4,6 @@ import { byRole, byText } from 'testing-library-selector'; import { setPluginLinksHook } from '@grafana/runtime'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; -import { setFolderAccessControl } from 'app/features/alerting/unified/mocks/server/configure'; import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; @@ -20,6 +19,7 @@ import { mockPromAlertingRule, } from '../../mocks'; import { grafanaRulerRule } from '../../mocks/grafanaRulerApi'; +import { grantPermissionsHelper } from '../../test/test-utils'; import { setupDataSources } from '../../testSetup/datasources'; import { Annotation } from '../../utils/constants'; import { DataSourceType } from '../../utils/datasource'; @@ -76,16 +76,6 @@ setPluginLinksHook(() => ({ isLoading: false, })); -/** - * "Grants" permissions via contextSrv mock, and additionally sets folder access control - * API response to match - */ -const grantPermissionsHelper = (permissions: AccessControlAction[]) => { - const permissionsHash = permissions.reduce((hash, permission) => ({ ...hash, [permission]: true }), {}); - grantUserPermissions(permissions); - setFolderAccessControl(permissionsHash); -}; - const openSilenceDrawer = async () => { const user = userEvent.setup(); await user.click(ELEMENTS.actions.more.button.get()); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx index bb5bd689b32..f1d3064fadb 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -5,9 +5,9 @@ import { useMeasure } from 'react-use'; import { NavModelItem, UrlQueryValue } from '@grafana/data'; import { Alert, LinkButton, LoadingBar, Stack, TabContent, Text, TextLink, useStyles2 } from '@grafana/ui'; -import { Trans, t } from '@grafana/ui/src/utils/i18n'; import { PageInfoItem } from 'app/core/components/Page/types'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { Trans, t } from 'app/core/internationalization'; import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule'; import { RuleActionsButtons } from 'app/features/alerting/unified/components/rules/RuleActionsButtons'; import { AlertInstanceTotalState, CombinedRule, RuleHealth, RuleIdentifier } from 'app/types/unified-alerting'; diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx index dcbc646ae03..f755d6ff9e3 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx @@ -1,17 +1,17 @@ +import { HttpResponse } from 'msw'; import { render } from 'test/test-utils'; import { byLabelText, byTestId, byText, byTitle } from 'testing-library-selector'; -import { CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { AccessControlAction } from 'app/types'; +import { RuleGroupIdentifier } from 'app/types/unified-alerting'; -import { - mockCombinedRule, - mockCombinedRuleNamespace, - mockDataSource, - mockPromAlertingRule, - mockPromRecordingRule, - mockRulerAlertingRule, - mockRulerRecordingRule, -} from '../../mocks'; +import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi'; +import server, { setupMswServer } from '../../mockApi'; +import { mimirDataSource } from '../../mocks/server/configure'; +import { alertingFactory } from '../../mocks/server/db'; +import { rulerRuleGroupHandler as grafanaRulerRuleGroupHandler } from '../../mocks/server/handlers/grafanaRuler'; +import { rulerRuleGroupHandler } from '../../mocks/server/handlers/mimirRuler'; +import { grantPermissionsHelper } from '../../test/test-utils'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { EditRuleGroupModal } from './EditRuleGroupModal'; @@ -29,133 +29,165 @@ const ui = { }; const noop = () => jest.fn(); +setupMswServer(); -describe('EditGroupModal', () => { +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + useReturnToPrevious: jest.fn(), +})); + +describe('EditGroupModal component on cloud alert rules', () => { it('Should disable all inputs but interval when intervalEditOnly is set', async () => { - const namespace = mockCombinedRuleNamespace({ - name: 'my-alerts', - rulesSource: mockDataSource(), - groups: [{ name: 'default-group', interval: '90s', rules: [], totals: {} }], + const { rulerConfig } = mimirDataSource(); + + const group = alertingFactory.ruler.group.build({ + rules: [alertingFactory.ruler.alertingRule.build(), alertingFactory.ruler.recordingRule.build()], }); - const group = namespace.groups[0]; + // @TODO need to simplify this a bit I think, ideally there would be a higher-level function that simply sets up a few rules + // and attaches the ruler and prometheus endpoint(s) – including the namespaces and group endpoints. + server.use( + rulerRuleGroupHandler({ + response: HttpResponse.json(group), + }) + ); - render(); + const rulerGroupIdentifier: RuleGroupIdentifier = { + dataSourceName: rulerConfig.dataSourceName, + groupName: 'default-group', + namespaceName: 'my-namespace', + }; + + render( + + ); expect(await ui.input.namespace.find()).toHaveAttribute('readonly'); expect(ui.input.group.get()).toHaveAttribute('readonly'); expect(ui.input.interval.get()).not.toHaveAttribute('readonly'); }); -}); - -describe('EditGroupModal component on cloud alert rules', () => { - const promDsSettings = mockDataSource({ name: 'Prometheus-1', uid: 'Prometheus-1' }); - - const alertingRule = mockCombinedRule({ - namespace: undefined, - promRule: mockPromAlertingRule({ name: 'alerting-rule-cpu' }), - rulerRule: mockRulerAlertingRule({ alert: 'alerting-rule-cpu' }), - }); - - const recordingRule1 = mockCombinedRule({ - namespace: undefined, - promRule: mockPromRecordingRule({ name: 'recording-rule-memory' }), - rulerRule: mockRulerRecordingRule({ record: 'recording-rule-memory' }), - }); - - const recordingRule2 = mockCombinedRule({ - namespace: undefined, - promRule: mockPromRecordingRule({ name: 'recording-rule-cpu' }), - rulerRule: mockRulerRecordingRule({ record: 'recording-rule-cpu' }), - }); it('Should show alert table in case of having some non-recording rules in the group', async () => { - const promNs = mockCombinedRuleNamespace({ - name: 'prometheus-ns', - rulesSource: promDsSettings, - groups: [ - { name: 'default-group', interval: '90s', rules: [alertingRule, recordingRule1, recordingRule2], totals: {} }, - ], + const { dataSource, rulerConfig } = mimirDataSource(); + + const group = alertingFactory.ruler.group.build({ + rules: [alertingFactory.ruler.alertingRule.build(), alertingFactory.ruler.recordingRule.build()], }); - const group = promNs.groups[0]; + // @TODO need to simplify this a bit I think, ideally there would be a higher-level function that simply sets up a few rules + // and attaches the ruler and prometheus endpoint(s) – including the namespaces and group endpoints. + server.use( + rulerRuleGroupHandler({ + response: HttpResponse.json(group), + }) + ); - render(); + const ruleGroupIdentifier: RuleGroupIdentifier = { + dataSourceName: dataSource.name, + groupName: group.name, + namespaceName: 'ns1', + }; - expect(await ui.input.namespace.find()).toHaveValue('prometheus-ns'); + render(); + + expect(await ui.input.namespace.find()).toHaveValue('ns1'); expect(ui.input.namespace.get()).not.toHaveAttribute('readonly'); - expect(ui.input.group.get()).toHaveValue('default-group'); + expect(ui.input.group.get()).toHaveValue(group.name); + + // @ts-ignore + const ruleName = group.rules.at(0).alert; expect(ui.tableRows.getAll()).toHaveLength(1); // Only one rule is non-recording - expect(ui.tableRows.getAll()[0]).toHaveTextContent('alerting-rule-cpu'); + expect(ui.tableRows.getAll().at(0)).toHaveTextContent(ruleName); }); it('Should not show alert table in case of having exclusively recording rules in the group', async () => { - const promNs = mockCombinedRuleNamespace({ - name: 'prometheus-ns', - rulesSource: promDsSettings, - groups: [{ name: 'default-group', interval: '90s', rules: [recordingRule1, recordingRule2], totals: {} }], + const { dataSource, rulerConfig } = mimirDataSource(); + + const group = alertingFactory.ruler.group.build({ + rules: [alertingFactory.ruler.recordingRule.build(), alertingFactory.ruler.recordingRule.build()], }); - const group = promNs.groups[0]; + // @TODO need to simplify this a bit I think + server.use( + rulerRuleGroupHandler({ + response: HttpResponse.json(group), + }) + ); - render(); + const ruleGroupIdentifier: RuleGroupIdentifier = { + dataSourceName: dataSource.name, + groupName: group.name, + namespaceName: 'ns1', + }; + + render(); expect(ui.table.query()).not.toBeInTheDocument(); expect(await ui.noRulesText.find()).toBeInTheDocument(); }); }); describe('EditGroupModal component on grafana-managed alert rules', () => { - const grafanaNamespace: CombinedRuleNamespace = { - name: 'namespace1', - rulesSource: GRAFANA_RULES_SOURCE_NAME, - groups: [ - { - name: 'grafanaGroup1', - interval: '30s', - rules: [ - mockCombinedRule({ - namespace: undefined, - promRule: mockPromAlertingRule({ name: 'high-cpu-1' }), - rulerRule: mockRulerAlertingRule({ alert: 'high-cpu-1' }), - }), - mockCombinedRule({ - namespace: undefined, - promRule: mockPromAlertingRule({ name: 'high-memory' }), - rulerRule: mockRulerAlertingRule({ alert: 'high-memory' }), - }), - ], - totals: {}, - }, - ], + // @TODO simplify folder stuff, should also have a higher-level function to set these up + const folder = alertingFactory.folder.build(); + const NAMESPACE_UID = folder.uid; + + const group = alertingFactory.ruler.group.build({ + rules: [alertingFactory.ruler.alertingRule.build(), alertingFactory.ruler.alertingRule.build()], + }); + + const ruleGroupIdentifier: RuleGroupIdentifier = { + dataSourceName: GRAFANA_RULES_SOURCE_NAME, + groupName: group.name, + namespaceName: NAMESPACE_UID, }; - const grafanaGroup1 = grafanaNamespace.groups[0]; + beforeEach(() => { + grantPermissionsHelper([ + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + ]); + + server.use( + grafanaRulerRuleGroupHandler({ + response: HttpResponse.json(group), + }) + ); + }); const renderWithGrafanaGroup = () => - render(); + render( + + ); it('Should show alert table', async () => { renderWithGrafanaGroup(); - expect(await ui.input.namespace.find()).toHaveValue('namespace1'); - expect(ui.input.group.get()).toHaveValue('grafanaGroup1'); - expect(ui.input.interval.get()).toHaveValue('30s'); + expect(await ui.input.namespace.find()).toHaveValue(NAMESPACE_UID); + expect(ui.input.group.get()).toHaveValue(group.name); + expect(ui.input.interval.get()).toHaveValue(group.interval); expect(ui.tableRows.getAll()).toHaveLength(2); - expect(ui.tableRows.getAll()[0]).toHaveTextContent('high-cpu-1'); - expect(ui.tableRows.getAll()[1]).toHaveTextContent('high-memory'); + // @ts-ignore + expect(ui.tableRows.getAll().at(0)).toHaveTextContent(group.rules.at(0).alert); + // @ts-ignore + expect(ui.tableRows.getAll().at(1)).toHaveTextContent(group.rules.at(1).alert); }); it('Should have folder input in readonly mode', async () => { renderWithGrafanaGroup(); - expect(await ui.input.namespace.find()).toHaveAttribute('readonly'); }); it('Should not display folder link if no folderUrl provided', async () => { renderWithGrafanaGroup(); - expect(await ui.input.namespace.find()).toHaveValue('namespace1'); + expect(await ui.input.namespace.find()).toHaveValue(NAMESPACE_UID); expect(ui.folderLink.query()).not.toBeInTheDocument(); }); }); diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index 76427e7d5fa..828920a7e52 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -4,32 +4,46 @@ import { useMemo } from 'react'; import { FieldValues, FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; -import { Alert, Badge, Button, Field, Input, Label, LinkButton, Modal, Stack, useStyles2 } from '@grafana/ui'; +import { + Alert, + Badge, + Button, + Field, + Input, + Label, + LinkButton, + LoadingPlaceholder, + Modal, + Stack, + useStyles2, +} from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; -import { Trans } from 'app/core/internationalization'; +import { Trans, t } from 'app/core/internationalization'; import { dispatch } from 'app/store/store'; -import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting'; -import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; +import { RuleGroupIdentifier, RulerDataSourceConfig } from 'app/types/unified-alerting'; +import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; +import { alertRuleApi } from '../../api/alertRuleApi'; import { useMoveRuleGroup, useRenameRuleGroup, useUpdateRuleGroupConfiguration, } from '../../hooks/ruleGroup/useUpdateRuleGroup'; import { anyOfRequestState } from '../../hooks/useAsync'; +import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults'; import { fetchRulerRulesAction, rulesInSameGroupHaveInvalidFor } from '../../state/actions'; import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; -import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { stringifyErrorLike } from '../../utils/misc'; -import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form'; import { AlertInfo, getAlertInfo, isGrafanaOrDataSourceRecordingRule } from '../../utils/rules'; import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../../utils/time'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; -import { decodeGrafanaNamespace, encodeGrafanaNamespace } from '../expressions/util'; import { EvaluationGroupQuickPick } from '../rule-editor/EvaluationGroupQuickPick'; import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior'; +const useRuleGroupDefinition = alertRuleApi.endpoints.getRuleGroupForNamespace.useQuery; + const ITEMS_PER_PAGE = 10; function ForBadge({ message, error }: { message: string; error?: boolean }) { @@ -170,17 +184,59 @@ export const evaluateEveryValidationOptions = (rules: Rul }); export interface ModalProps { - namespace: CombinedRuleNamespace; - group: CombinedRuleGroup; + ruleGroupIdentifier: RuleGroupIdentifier; + folderTitle?: string; + rulerConfig: RulerDataSourceConfig; onClose: (saved?: boolean) => void; intervalEditOnly?: boolean; folderUrl?: string; - folderUid?: string; hideFolder?: boolean; } -export function EditRuleGroupModal(props: ModalProps): React.ReactElement { - const { namespace, group, onClose, intervalEditOnly, folderUid } = props; +export interface ModalFormProps { + ruleGroupIdentifier: RuleGroupIdentifier; + folderTitle?: string; // used to display the GMA folder title + ruleGroup: RulerRuleGroupDTO; + onClose: (saved?: boolean) => void; + intervalEditOnly?: boolean; + folderUrl?: string; + hideFolder?: boolean; +} + +// this component just wraps the modal with some loading state for grabbing rules and such +export function EditRuleGroupModal(props: ModalProps) { + const { ruleGroupIdentifier, rulerConfig, intervalEditOnly, onClose } = props; + const rulesSourceName = ruleGroupIdentifier.dataSourceName; + const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME; + + const modalTitle = + intervalEditOnly || isGrafanaManagedGroup ? 'Edit evaluation group' : 'Edit namespace or evaluation group'; + + const styles = useStyles2(getStyles); + + const { + data: ruleGroup, + error, + isLoading, + } = useRuleGroupDefinition({ + group: ruleGroupIdentifier.groupName, + namespace: ruleGroupIdentifier.namespaceName, + rulerConfig, + }); + + const loadingText = t('alerting.common.loading', 'Loading...'); + + return ( + + {isLoading && } + {error ? stringifyErrorLike(error) : null} + {ruleGroup && } + + ); +} + +export function EditRuleGroupModalForm(props: ModalFormProps): React.ReactElement { + const { ruleGroup, ruleGroupIdentifier, folderTitle, onClose, intervalEditOnly } = props; const styles = useStyles2(getStyles); const notifyApp = useAppNotification(); @@ -200,32 +256,21 @@ export function EditRuleGroupModal(props: ModalProps): React.ReactElement { const defaultValues = useMemo( (): FormValues => ({ - namespaceName: decodeGrafanaNamespace(namespace).name, - groupName: group.name, - groupInterval: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, + namespaceName: ruleGroupIdentifier.namespaceName, + groupName: ruleGroupIdentifier.groupName, + groupInterval: ruleGroup?.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, }), - [namespace, group.name, group.interval] + [ruleGroup?.interval, ruleGroupIdentifier.groupName, ruleGroupIdentifier.namespaceName] ); - const rulesSourceName = getRulesSourceName(namespace.rulesSource); + const rulesSourceName = ruleGroupIdentifier.dataSourceName; const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME; - // parse any parent folders the alert rule might be stored in - const nestedFolderParents = decodeGrafanaNamespace(namespace).parents; - const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace'; const onSubmit = async (values: FormValues) => { - const ruleGroupIdentifier: RuleGroupIdentifier = { - dataSourceName: rulesSourceName, - groupName: group.name, - namespaceName: isGrafanaManagedGroup ? folderUid! : namespace.name, - }; - // make sure that when dealing with a nested folder for Grafana managed rules we encode the folder properly - const updatedNamespaceName = isGrafanaManagedGroup - ? encodeGrafanaNamespace(values.namespaceName, nestedFolderParents) - : values.namespaceName; + const updatedNamespaceName = values.namespaceName; const updatedGroupName = values.groupName; const updatedInterval = values.groupInterval; @@ -266,136 +311,133 @@ export function EditRuleGroupModal(props: ModalProps): React.ReactElement { }; const rulesWithoutRecordingRules = compact( - group.rules.map((r) => r.rulerRule).filter((rule) => !isGrafanaOrDataSourceRecordingRule(rule)) + ruleGroup?.rules.filter((rule) => !isGrafanaOrDataSourceRecordingRule(rule)) ); const hasSomeNoRecordingRules = rulesWithoutRecordingRules.length > 0; - const modalTitle = - intervalEditOnly || isGrafanaManagedGroup ? 'Edit evaluation group' : 'Edit namespace or evaluation group'; return ( - - -
- <> - {!props.hideFolder && ( - - - {nameSpaceLabel} - - } - invalid={Boolean(errors.namespaceName) ? true : undefined} - error={errors.namespaceName?.message} - > - - - {isGrafanaManagedGroup && props.folderUrl && ( - - )} - - )} - - Evaluation group - - } - invalid={!!errors.groupName} - error={errors.groupName?.message} - > - - - - Evaluation interval - - } - invalid={Boolean(errors.groupInterval) ? true : undefined} - error={errors.groupInterval?.message} - > - + + + <> + {!props.hideFolder && ( + + + {nameSpaceLabel} + + } + invalid={Boolean(errors.namespaceName) ? true : undefined} + error={errors.namespaceName?.message} + > - setValue('groupInterval', value, { shouldValidate: true, shouldDirty: true })} - /> - - - - {/* if we're dealing with a Grafana-managed group, check if the evaluation interval is valid / permitted */} - {isGrafanaManagedGroup && checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && ( - - )} - - {!hasSomeNoRecordingRules &&
This group does not contain alert rules.
} - {hasSomeNoRecordingRules && ( - <> -
List of rules that belong to this group
-
- #Eval column represents the number of evaluations needed before alert starts firing. -
- - - )} - {error && {stringifyErrorLike(error)}} -
- - - - -
- - -
-
+ icon="folder-open" + target="_blank" + /> + )} +
+ )} + + Evaluation group + + } + invalid={!!errors.groupName} + error={errors.groupName?.message} + > + + + + Evaluation interval + + } + invalid={Boolean(errors.groupInterval) ? true : undefined} + error={errors.groupInterval?.message} + > + + + setValue('groupInterval', value, { shouldValidate: true, shouldDirty: true })} + /> + + + + {/* if we're dealing with a Grafana-managed group, check if the evaluation interval is valid / permitted */} + {isGrafanaManagedGroup && checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && ( + + )} + + {!hasSomeNoRecordingRules &&
This group does not contain alert rules.
} + {hasSomeNoRecordingRules && ( + <> +
List of rules that belong to this group
+
+ #Eval column represents the number of evaluations needed before alert starts firing. +
+ + + )} + {error && {stringifyErrorLike(error)}} +
+ + + + +
+ + + ); } diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx index d79e630ffb5..9baab7600cd 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx @@ -7,12 +7,14 @@ import { byRole, byTestId, byText } from 'testing-library-selector'; import { contextSrv } from 'app/core/services/context_srv'; import { configureStore } from 'app/store/configureStore'; import { AccessControlAction } from 'app/types'; -import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { CombinedRuleGroup, CombinedRuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting'; import * as analytics from '../../Analytics'; +import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi'; import { useHasRuler } from '../../hooks/useHasRuler'; import { mockExportApi, mockFolderApi, setupMswServer } from '../../mockApi'; import { grantUserPermissions, mockCombinedRule, mockDataSource, mockFolder, mockGrafanaRulerRule } from '../../mocks'; +import { mimirDataSource } from '../../mocks/server/configure'; import { RulesGroup } from './RulesGroup'; @@ -38,10 +40,10 @@ const mocks = { useHasRuler: jest.mocked(useHasRuler), }; -function mockUseHasRuler(hasRuler: boolean, rulerRulesLoaded: boolean) { +function mockUseHasRuler(hasRuler: boolean, rulerConfig: RulerDataSourceConfig) { mocks.useHasRuler.mockReturnValue({ hasRuler, - rulerRulesLoaded, + rulerConfig, }); } @@ -107,7 +109,7 @@ describe('Rules group tests', () => { it('Should hide delete and edit group buttons', async () => { // Act - mockUseHasRuler(true, true); + mockUseHasRuler(true, GRAFANA_RULER_CONFIG); mockFolderApi(server).folder('cpu-usage', mockFolder({ uid: 'cpu-usage', canSave: false })); renderRulesGroup(namespace, group); expect(await screen.findByTestId('rule-group')).toBeInTheDocument(); @@ -119,7 +121,7 @@ describe('Rules group tests', () => { it('Should allow exporting rules group', async () => { // Arrange - mockUseHasRuler(true, true); + mockUseHasRuler(true, GRAFANA_RULER_CONFIG); mockFolderApi(server).folder('cpu-usage', mockFolder({ uid: 'cpu-usage' })); mockExportApi(server).exportRulesGroup('cpu-usage', 'TestGroup', { yaml: 'Yaml Export Content', @@ -151,6 +153,8 @@ describe('Rules group tests', () => { }); describe('Cloud rules', () => { + const { rulerConfig } = mimirDataSource(); + beforeEach(() => { contextSrv.isEditor = true; }); @@ -169,7 +173,7 @@ describe('Rules group tests', () => { it('When ruler enabled should display delete and edit group buttons', () => { // Arrange - mockUseHasRuler(true, true); + mockUseHasRuler(true, rulerConfig); // Act renderRulesGroup(namespace, group); @@ -182,7 +186,7 @@ describe('Rules group tests', () => { it('When ruler disabled should hide delete and edit group buttons', () => { // Arrange - mockUseHasRuler(false, false); + mockUseHasRuler(false, rulerConfig); // Act renderRulesGroup(namespace, group); @@ -195,7 +199,7 @@ describe('Rules group tests', () => { it('Delete button click should display confirmation modal', async () => { // Arrange - mockUseHasRuler(true, true); + mockUseHasRuler(true, rulerConfig); // Act renderRulesGroup(namespace, group); @@ -206,36 +210,4 @@ describe('Rules group tests', () => { expect(ui.confirmDeleteModal.confirmButton.get()).toBeInTheDocument(); }); }); - - describe('Analytics', () => { - beforeEach(() => { - contextSrv.isEditor = true; - }); - - const group: CombinedRuleGroup = { - name: 'TestGroup', - rules: [mockCombinedRule()], - totals: {}, - }; - - const namespace: CombinedRuleNamespace = { - name: 'TestNamespace', - rulesSource: mockDataSource(), - groups: [group], - }; - - it('Should log info when closing the edit group rule modal without saving', async () => { - mockUseHasRuler(true, true); - renderRulesGroup(namespace, group); - - await userEvent.click(ui.editGroupButton.get()); - - expect(screen.getByText('Cancel')).toBeInTheDocument(); - - await userEvent.click(screen.getByText('Cancel')); - - expect(screen.queryByText('Cancel')).not.toBeInTheDocument(); - expect(analytics.logInfo).toHaveBeenCalledWith(analytics.LogMessages.leavingRuleGroupEdit); - }); - }); }); diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index b1a5bddd43c..ccf725b5f99 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import pluralize from 'pluralize'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -26,7 +26,7 @@ import { ActionIcon } from './ActionIcon'; import { EditRuleGroupModal } from './EditRuleGroupModal'; import { ReorderCloudGroupModal } from './ReorderRuleGroupModal'; import { RuleGroupStats } from './RuleStats'; -import { RulesTable } from './RulesTable'; +import { RulesTable, useIsRulesLoading } from './RulesTable'; type ViewMode = 'grouped' | 'list'; @@ -42,6 +42,7 @@ const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: Props) => { const { rulesSource } = namespace; const rulesSourceName = getRulesSourceName(rulesSource); + const rulerRulesLoaded = useIsRulesLoading(rulesSource); const [deleteRuleGroup] = useDeleteRuleGroup(); const styles = useStyles2(getStyles); @@ -58,7 +59,7 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: setIsCollapsed(!expandAll); }, [expandAll]); - const { hasRuler, rulerRulesLoaded } = useHasRuler(namespace.rulesSource); + const { hasRuler, rulerConfig } = useHasRuler(namespace.rulesSource); const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ rulesSourceName }); const rulerRule = group.rules[0]?.rulerRule; @@ -78,12 +79,15 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: const isListView = viewMode === 'list'; const isGroupView = viewMode === 'grouped'; - const deleteGroup = async () => { - const namespaceName = decodeGrafanaNamespace(namespace).name; + const ruleGroupIdentifier = useMemo(() => { + const namespaceName = namespace.uid ?? namespace.name; const groupName = group.name; const dataSourceName = getRulesSourceName(namespace.rulesSource); - const ruleGroupIdentifier: RuleGroupIdentifier = { namespaceName, groupName, dataSourceName }; + return { namespaceName, groupName, dataSourceName }; + }, [namespace, group.name]); + + const deleteGroup = async () => { await deleteRuleGroup.execute(ruleGroupIdentifier); setIsDeletingGroup(false); }; @@ -274,13 +278,13 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: rules={group.rules} /> )} - {isEditingGroup && ( + {isEditingGroup && rulerConfig && ( closeEditModal()} folderUrl={folder?.canEdit ? makeFolderSettingsLink(folder.uid) : undefined} - folderUid={folderUID} /> )} {isReorderingGroup && dsFeatures?.rulerConfig && ( diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index 38eb1045e40..1f7c6c44b11 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -4,7 +4,7 @@ import Skeleton from 'react-loading-skeleton'; import { GrafanaTheme2 } from '@grafana/data'; import { Pagination, Tooltip, useStyles2 } from '@grafana/ui'; -import { CombinedRule } from 'app/types/unified-alerting'; +import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants'; import { alertRuleApi } from '../../api/alertRuleApi'; @@ -14,6 +14,7 @@ import { useAsync } from '../../hooks/useAsync'; import { attachRulerRuleToCombinedRule } from '../../hooks/useCombinedRuleNamespaces'; import { useHasRuler } from '../../hooks/useHasRuler'; import { usePagination } from '../../hooks/usePagination'; +import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { PluginOriginBadge } from '../../plugins/PluginOriginBadge'; import { calculateNextEvaluationEstimate } from '../../rule-list/components/util'; import { Annotation } from '../../utils/constants'; @@ -328,8 +329,20 @@ function RuleActionsCell({ rule, isLoadingRuler }: { rule: CombinedRule; isLoadi ); } +export function useIsRulesLoading(rulesSource: RulesSource) { + const rulerRules = useUnifiedAlertingSelector((state) => state.rulerRules); + const rulesSourceName = getRulesSourceName(rulesSource); + + const rulerRulesLoaded = Boolean(rulerRules[rulesSourceName]?.result); + return rulerRulesLoaded; +} + function useRuleStatus(rule: CombinedRule) { - const { hasRuler, rulerRulesLoaded } = useHasRuler(rule.namespace.rulesSource); + const rulesSource = rule.namespace.rulesSource; + + const rulerRulesLoaded = useIsRulesLoading(rulesSource); + const { hasRuler } = useHasRuler(rulesSource); + const { promRule, rulerRule } = rule; // If prometheusRulesPrimary is enabled, we don't fetch rules from the Ruler API (except for Grafana managed rules) diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/useProduceNewRuleGroup.ts b/public/app/features/alerting/unified/hooks/ruleGroup/useProduceNewRuleGroup.ts index f7c54c1121b..6d8d890e737 100644 --- a/public/app/features/alerting/unified/hooks/ruleGroup/useProduceNewRuleGroup.ts +++ b/public/app/features/alerting/unified/hooks/ruleGroup/useProduceNewRuleGroup.ts @@ -7,7 +7,7 @@ import { alertRuleApi } from '../../api/alertRuleApi'; import { featureDiscoveryApi } from '../../api/featureDiscoveryApi'; import { notFoundToNullOrThrow } from '../../api/util'; import { ruleGroupReducer } from '../../reducers/ruler/ruleGroups'; -import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form'; +import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults'; const { useLazyGetRuleGroupForNamespaceQuery } = alertRuleApi; const { useLazyDiscoverDsFeaturesQuery } = featureDiscoveryApi; diff --git a/public/app/features/alerting/unified/hooks/useHasRuler.ts b/public/app/features/alerting/unified/hooks/useHasRuler.ts index db2e85f09ee..399aca6081f 100644 --- a/public/app/features/alerting/unified/hooks/useHasRuler.ts +++ b/public/app/features/alerting/unified/hooks/useHasRuler.ts @@ -3,19 +3,14 @@ import { RulesSource } from 'app/types/unified-alerting'; import { featureDiscoveryApi } from '../api/featureDiscoveryApi'; import { getRulesSourceName } from '../utils/datasource'; -import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; - const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; // datasource has ruler if the discovery api returns a rulerConfig export function useHasRuler(rulesSource: RulesSource) { - const rulerRules = useUnifiedAlertingSelector((state) => state.rulerRules); const rulesSourceName = getRulesSourceName(rulesSource); const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ rulesSourceName }); - const hasRuler = Boolean(dsFeatures?.rulerConfig); - const rulerRulesLoaded = Boolean(rulerRules[rulesSourceName]?.result); - return { hasRuler, rulerRulesLoaded }; + return { hasRuler, rulerConfig: dsFeatures?.rulerConfig }; } diff --git a/public/app/features/alerting/unified/hooks/useUnifiedAlertingSelector.ts b/public/app/features/alerting/unified/hooks/useUnifiedAlertingSelector.ts index 7b50599934c..5e6daa81e8b 100644 --- a/public/app/features/alerting/unified/hooks/useUnifiedAlertingSelector.ts +++ b/public/app/features/alerting/unified/hooks/useUnifiedAlertingSelector.ts @@ -4,6 +4,9 @@ import { StoreState, useSelector } from 'app/types'; import { UnifiedAlertingState } from '../state/reducers'; +/** + * @deprecated: DO NOT USE THIS; when using this you are INCORRECTLY assuming that we already have dispatched an action to populate the redux store values + */ export function useUnifiedAlertingSelector( selector: (state: UnifiedAlertingState) => TSelected, equalityFn?: (left: TSelected, right: TSelected) => boolean diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 5676dbb9a04..278178b395e 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -196,15 +196,12 @@ export const mockRulerAlertingRule = (partial: Partial = { ...partial, }); -export const mockRulerRecordingRule = (partial: Partial = {}): RulerAlertingRuleDTO => ({ - alert: 'alert1', +export const mockRulerRecordingRule = (partial: Partial = {}): RulerRecordingRuleDTO => ({ + record: 'alert1', expr: 'up = 1', labels: { severity: 'warning', }, - annotations: { - summary: 'test alert', - }, ...partial, }); @@ -735,7 +732,7 @@ export function mockStore(recipe: (state: StoreState) => void) { return configureStore(produce(defaultState, recipe)); } -export function mockAlertQuery(query: Partial): AlertQuery { +export function mockAlertQuery(query: Partial = {}): AlertQuery { return { datasourceUid: '--uid--', refId: 'A', diff --git a/public/app/features/alerting/unified/mocks/server/configure.ts b/public/app/features/alerting/unified/mocks/server/configure.ts index 063a070a10f..bc638438e14 100644 --- a/public/app/features/alerting/unified/mocks/server/configure.ts +++ b/public/app/features/alerting/unified/mocks/server/configure.ts @@ -20,6 +20,7 @@ import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridg import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { FolderDTO } from 'app/types'; +import { RulerDataSourceConfig } from 'app/types/unified-alerting'; import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { setupDataSources } from '../../testSetup/datasources'; @@ -108,9 +109,15 @@ export function mimirDataSource() { { alerting: true, module: 'core:plugin/prometheus' } ); + const rulerConfig: RulerDataSourceConfig = { + apiVersion: 'config', + dataSourceUid: dataSource.uid, + dataSourceName: dataSource.name, + }; + setupDataSources(dataSource); - return { dataSource }; + return { dataSource, rulerConfig }; } export function setPrometheusRules(ds: DataSourceInstanceSettings, groups: PromRuleGroupDTO[]) { diff --git a/public/app/features/alerting/unified/mocks/server/db.ts b/public/app/features/alerting/unified/mocks/server/db.ts index 55e76f78208..ba78b0be31a 100644 --- a/public/app/features/alerting/unified/mocks/server/db.ts +++ b/public/app/features/alerting/unified/mocks/server/db.ts @@ -1,18 +1,23 @@ import { Factory } from 'fishery'; +import { uniqueId } from 'lodash'; import { DataSourceInstanceSettings, PluginType } from '@grafana/data'; import { config, setDataSourceSrv } from '@grafana/runtime'; +import { FolderDTO } from 'app/types'; import { PromAlertingRuleDTO, PromAlertingRuleState, PromRuleGroupDTO, PromRuleType, + RulerAlertingRuleDTO, + RulerRecordingRuleDTO, + RulerRuleGroupDTO, } from 'app/types/unified-alerting-dto'; import { MockDataSourceSrv } from '../../mocks'; import { DataSourceType } from '../../utils/datasource'; -const ruleFactory = Factory.define(({ sequence }) => ({ +const prometheusRuleFactory = Factory.define(({ sequence }) => ({ name: `test-rule-${sequence}`, query: 'test-query', state: PromAlertingRuleState.Inactive, @@ -21,15 +26,35 @@ const ruleFactory = Factory.define(({ sequence }) => ({ labels: { team: 'infra' }, })); -const groupFactory = Factory.define(({ sequence }) => { +const rulerAlertingRuleFactory = Factory.define(({ sequence }) => ({ + alert: `ruler-alerting-rule-${sequence}`, + expr: 'vector(0)', + annotations: { 'annotation-key-1': 'annotation-value-1' }, + labels: { 'label-key-1': 'label-value-1' }, + for: '5m', +})); + +const rulerRecordingRuleFactory = Factory.define(({ sequence }) => ({ + record: `ruler-recording-rule-${sequence}`, + expr: 'vector(0)', + labels: { 'label-key-1': 'label-value-1' }, +})); + +const rulerRuleGroupFactory = Factory.define(({ sequence }) => ({ + name: `ruler-rule-group-${sequence}`, + rules: [], + interval: '1m', +})); + +const prometheusRuleGroupFactory = Factory.define(({ sequence }) => { const group = { name: `test-group-${sequence}`, file: `test-namespace`, interval: 10, - rules: ruleFactory.buildList(10), + rules: prometheusRuleFactory.buildList(10), }; - ruleFactory.rewindSequence(); + prometheusRuleFactory.rewindSequence(); return group; }); @@ -72,8 +97,33 @@ const dataSourceFactory = Factory.define(({ sequence }; }); +const grafanaFolderFactory = Factory.define(({ sequence }) => ({ + id: sequence, + uid: uniqueId(), + title: `Mock Folder ${sequence}`, + version: 1, + url: '', + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + created: '', + createdBy: '', + hasAcl: false, + updated: '', + updatedBy: '', +})); + export const alertingFactory = { - group: groupFactory, - rule: ruleFactory, + folder: grafanaFolderFactory, + prometheus: { + group: prometheusRuleGroupFactory, + rule: prometheusRuleFactory, + }, + ruler: { + group: rulerRuleGroupFactory, + alertingRule: rulerAlertingRuleFactory, + recordingRule: rulerRecordingRuleFactory, + }, dataSource: dataSourceFactory, }; diff --git a/public/app/features/alerting/unified/reducers/ruler/__snapshots__/ruleGroups.test.ts.snap b/public/app/features/alerting/unified/reducers/ruler/__snapshots__/ruleGroups.test.ts.snap index 95329f16627..42aac7884e8 100644 --- a/public/app/features/alerting/unified/reducers/ruler/__snapshots__/ruleGroups.test.ts.snap +++ b/public/app/features/alerting/unified/reducers/ruler/__snapshots__/ruleGroups.test.ts.snap @@ -104,10 +104,6 @@ exports[`removing a rule should remove a Data source managed ruler rule without }, }, { - "alert": "alert1", - "annotations": { - "summary": "test alert", - }, "expr": "up = 1", "labels": { "severity": "warning", diff --git a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx b/public/app/features/alerting/unified/rule-editor/CloneRuleEditor.test.tsx similarity index 93% rename from public/app/features/alerting/unified/CloneRuleEditor.test.tsx rename to public/app/features/alerting/unified/rule-editor/CloneRuleEditor.test.tsx index 7399bcae26b..298be9b1529 100644 --- a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/CloneRuleEditor.test.tsx @@ -4,36 +4,36 @@ import { getWrapper, render, waitFor, waitForElementToBeRemoved, within } from ' import { byRole, byTestId, byText } from 'testing-library-selector'; import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants'; +import { AccessControlAction } from 'app/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; - -import { AccessControlAction } from '../../../types'; import { RulerAlertingRuleDTO, RulerGrafanaRuleDTO, RulerRecordingRuleDTO, RulerRuleDTO, -} from '../../../types/unified-alerting-dto'; +} from 'app/types/unified-alerting-dto'; -import { CloneRuleEditor, cloneRuleDefinition } from './CloneRuleEditor'; -import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { setupMswServer } from './mockApi'; +import { ExpressionEditorProps } from '../components/rule-editor/ExpressionEditor'; +import { setupMswServer } from '../mockApi'; import { grantUserPermissions, mockDataSource, mockRulerAlertingRule, mockRulerGrafanaRule, mockRulerRuleGroup, -} from './mocks'; -import { grafanaRulerRule } from './mocks/grafanaRulerApi'; -import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from './mocks/rulerApi'; -import { AlertingQueryRunner } from './state/AlertingQueryRunner'; -import { setupDataSources } from './testSetup/datasources'; -import { RuleFormValues } from './types/rule-form'; -import { Annotation } from './utils/constants'; -import { getDefaultFormValues } from './utils/rule-form'; -import { hashRulerRule } from './utils/rule-id'; +} from '../mocks'; +import { grafanaRulerRule } from '../mocks/grafanaRulerApi'; +import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from '../mocks/rulerApi'; +import { AlertingQueryRunner } from '../state/AlertingQueryRunner'; +import { setupDataSources } from '../testSetup/datasources'; +import { RuleFormValues } from '../types/rule-form'; +import { Annotation } from '../utils/constants'; +import { hashRulerRule } from '../utils/rule-id'; -jest.mock('./components/rule-editor/ExpressionEditor', () => ({ +import { CloneRuleEditor, cloneRuleDefinition } from './CloneRuleEditor'; +import { getDefaultFormValues } from './formDefaults'; + +jest.mock('../components/rule-editor/ExpressionEditor', () => ({ // eslint-disable-next-line react/display-name ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( onChange(e.target.value)} /> diff --git a/public/app/features/alerting/unified/CloneRuleEditor.tsx b/public/app/features/alerting/unified/rule-editor/CloneRuleEditor.tsx similarity index 72% rename from public/app/features/alerting/unified/CloneRuleEditor.tsx rename to public/app/features/alerting/unified/rule-editor/CloneRuleEditor.tsx index 92745f20bde..e92ad4b46e6 100644 --- a/public/app/features/alerting/unified/CloneRuleEditor.tsx +++ b/public/app/features/alerting/unified/rule-editor/CloneRuleEditor.tsx @@ -1,18 +1,17 @@ import { cloneDeep } from 'lodash'; -import { locationService } from '@grafana/runtime/src'; -import { Alert, LoadingPlaceholder } from '@grafana/ui/src'; +import { locationService } from '@grafana/runtime'; +import { Alert, LoadingPlaceholder } from '@grafana/ui'; +import { RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting'; +import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; -import { RuleIdentifier, RuleWithLocation } from '../../../types/unified-alerting'; -import { RulerRuleDTO } from '../../../types/unified-alerting-dto'; - -import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm'; -import { useRuleWithLocation } from './hooks/useCombinedRule'; -import { generateCopiedName } from './utils/duplicate'; -import { stringifyErrorLike } from './utils/misc'; -import { rulerRuleToFormValues } from './utils/rule-form'; -import { getRuleName, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './utils/rules'; -import { createRelativeUrl } from './utils/url'; +import { AlertRuleForm } from '../components/rule-editor/alert-rule-form/AlertRuleForm'; +import { useRuleWithLocation } from '../hooks/useCombinedRule'; +import { generateCopiedName } from '../utils/duplicate'; +import { stringifyErrorLike } from '../utils/misc'; +import { rulerRuleToFormValues } from '../utils/rule-form'; +import { getRuleName, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from '../utils/rules'; +import { createRelativeUrl } from '../utils/url'; export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier }) { const { loading, result: rule, error } = useRuleWithLocation({ ruleIdentifier: sourceRuleId }); diff --git a/public/app/features/alerting/unified/ExistingRuleEditor.tsx b/public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx similarity index 71% rename from public/app/features/alerting/unified/ExistingRuleEditor.tsx rename to public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx index 17dc0d74753..7d1b9d92fcf 100644 --- a/public/app/features/alerting/unified/ExistingRuleEditor.tsx +++ b/public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx @@ -1,19 +1,18 @@ import { Alert, LoadingPlaceholder } from '@grafana/ui'; import { RuleIdentifier } from 'app/types/unified-alerting'; -import { AlertWarning } from './AlertWarning'; -import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm'; -import { useRuleWithLocation } from './hooks/useCombinedRule'; -import { useIsRuleEditable } from './hooks/useIsRuleEditable'; -import { stringifyErrorLike } from './utils/misc'; -import * as ruleId from './utils/rule-id'; +import { AlertWarning } from '../AlertWarning'; +import { AlertRuleForm } from '../components/rule-editor/alert-rule-form/AlertRuleForm'; +import { useRuleWithLocation } from '../hooks/useCombinedRule'; +import { useIsRuleEditable } from '../hooks/useIsRuleEditable'; +import { stringifyErrorLike } from '../utils/misc'; +import * as ruleId from '../utils/rule-id'; interface ExistingRuleEditorProps { identifier: RuleIdentifier; - id?: string; } -export function ExistingRuleEditor({ identifier, id }: ExistingRuleEditorProps) { +export function ExistingRuleEditor({ identifier }: ExistingRuleEditorProps) { const { loading: loadingAlertRule, result: ruleWithLocation, diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx similarity index 69% rename from public/app/features/alerting/unified/RuleEditor.tsx rename to public/app/features/alerting/unified/rule-editor/RuleEditor.tsx index bdda3e5d28b..b5adba57cf2 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx @@ -5,14 +5,16 @@ import { NavModelItem } from '@grafana/data'; import { withErrorBoundary } from '@grafana/ui'; import { RuleIdentifier } from 'app/types/unified-alerting'; -import { AlertWarning } from './AlertWarning'; +import { AlertWarning } from '../AlertWarning'; +import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; +import { AlertRuleForm } from '../components/rule-editor/alert-rule-form/AlertRuleForm'; +import { useURLSearchParams } from '../hooks/useURLSearchParams'; +import { useRulesAccess } from '../utils/accessControlHooks'; +import * as ruleId from '../utils/rule-id'; + import { CloneRuleEditor } from './CloneRuleEditor'; import { ExistingRuleEditor } from './ExistingRuleEditor'; -import { AlertingPageWrapper } from './components/AlertingPageWrapper'; -import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm'; -import { useURLSearchParams } from './hooks/useURLSearchParams'; -import { useRulesAccess } from './utils/accessControlHooks'; -import * as ruleId from './utils/rule-id'; +import { formValuesFromQueryParams, translateRouteParamToRuleType } from './formDefaults'; type RuleEditorPathParams = { id?: string; @@ -44,14 +46,8 @@ const getPageNav = (identifier?: RuleIdentifier, type?: RuleEditorPathParams['ty }; const RuleEditor = () => { - const [searchParams] = useURLSearchParams(); - const params = useParams(); - const { type } = params; - const id = ruleId.getRuleIdFromPathname(params); - const identifier = ruleId.tryParse(id, true); - - const copyFromId = searchParams.get('copyFrom') ?? undefined; - const copyFromIdentifier = ruleId.tryParse(copyFromId); + const { identifier, type } = useRuleEditorPathParams(); + const { copyFromIdentifier, queryDefaults } = useRuleEditorQueryParams(); const { canCreateGrafanaRules, canCreateCloudRules, canEditRules } = useRulesAccess(); @@ -65,15 +61,15 @@ const RuleEditor = () => { } if (identifier) { - return ; + return ; } if (copyFromIdentifier) { return ; } // new alert rule - return ; - }, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier]); + return ; + }, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, identifier, queryDefaults]); return ( @@ -83,3 +79,28 @@ const RuleEditor = () => { }; export default withErrorBoundary(RuleEditor, { style: 'page' }); + +function useRuleEditorPathParams() { + const params = useParams(); + const { type } = params; + const id = ruleId.getRuleIdFromPathname(params); + const identifier = ruleId.tryParse(id, true); + + return { identifier, type }; +} + +function useRuleEditorQueryParams() { + const { type } = useParams(); + + const [searchParams] = useURLSearchParams(); + const copyFromId = searchParams.get('copyFrom') ?? undefined; + const copyFromIdentifier = ruleId.tryParse(copyFromId); + + const ruleType = translateRouteParamToRuleType(type); + + const queryDefaults = searchParams.has('defaults') + ? formValuesFromQueryParams(searchParams.get('defaults') ?? '', ruleType) + : undefined; + + return { copyFromIdentifier, queryDefaults }; +} diff --git a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorCloudOnlyAllowed.test.tsx similarity index 88% rename from public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx rename to public/app/features/alerting/unified/rule-editor/RuleEditorCloudOnlyAllowed.test.tsx index e99c56856d2..3671d13d232 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorCloudOnlyAllowed.test.tsx @@ -6,24 +6,24 @@ import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types'; import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto'; -import { discoverFeaturesByUid } from './api/buildInfo'; -import { fetchRulerRulesGroup } from './api/ruler'; -import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { setupMswServer } from './mockApi'; -import { grantUserPermissions, mockDataSource } from './mocks'; -import { setupDataSources } from './testSetup/datasources'; -import { DataSourceType, GRAFANA_DATASOURCE_NAME, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { discoverFeaturesByUid } from '../api/buildInfo'; +import { fetchRulerRulesGroup } from '../api/ruler'; +import { ExpressionEditorProps } from '../components/rule-editor/ExpressionEditor'; +import { setupMswServer } from '../mockApi'; +import { grantUserPermissions, mockDataSource } from '../mocks'; +import { setupDataSources } from '../testSetup/datasources'; +import { DataSourceType, GRAFANA_DATASOURCE_NAME, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; -jest.mock('./components/rule-editor/ExpressionEditor', () => ({ +jest.mock('../components/rule-editor/ExpressionEditor', () => ({ // eslint-disable-next-line react/display-name ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( onChange(e.target.value)} /> ), })); -jest.mock('./api/buildInfo'); -jest.mock('./api/ruler', () => ({ - rulerUrlBuilder: jest.requireActual('./api/ruler').rulerUrlBuilder, +jest.mock('../api/buildInfo'); +jest.mock('../api/ruler', () => ({ + rulerUrlBuilder: jest.requireActual('../api/ruler').rulerUrlBuilder, fetchRulerRules: jest.fn(), fetchRulerRulesGroup: jest.fn(), fetchRulerRulesNamespace: jest.fn(), @@ -36,8 +36,8 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({ QueryEditorRow: () =>

hi

, })); -jest.mock('./components/rule-editor/util', () => { - const originalModule = jest.requireActual('./components/rule-editor/util'); +jest.mock('../components/rule-editor/util', () => { + const originalModule = jest.requireActual('../components/rule-editor/util'); return { ...originalModule, getThresholdsForQueries: jest.fn(() => ({})), diff --git a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorCloudRules.test.tsx similarity index 85% rename from public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx rename to public/app/features/alerting/unified/rule-editor/RuleEditorCloudRules.test.tsx index 21ba09cb12f..d70177fc7a9 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorCloudRules.test.tsx @@ -5,15 +5,15 @@ import { screen } from 'test/test-utils'; import { selectors } from '@grafana/e2e-selectors'; import { AccessControlAction } from 'app/types'; -import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { setupMswServer } from './mockApi'; -import { grantUserPermissions } from './mocks'; -import { GROUP_3, NAMESPACE_2 } from './mocks/mimirRulerApi'; -import { mimirDataSource } from './mocks/server/configure'; -import { MIMIR_DATASOURCE_UID } from './mocks/server/constants'; -import { captureRequests, serializeRequests } from './mocks/server/events'; +import { ExpressionEditorProps } from '../components/rule-editor/ExpressionEditor'; +import { setupMswServer } from '../mockApi'; +import { grantUserPermissions } from '../mocks'; +import { GROUP_3, NAMESPACE_2 } from '../mocks/mimirRulerApi'; +import { mimirDataSource } from '../mocks/server/configure'; +import { MIMIR_DATASOURCE_UID } from '../mocks/server/constants'; +import { captureRequests, serializeRequests } from '../mocks/server/events'; -jest.mock('./components/rule-editor/ExpressionEditor', () => ({ +jest.mock('../components/rule-editor/ExpressionEditor', () => ({ // eslint-disable-next-line react/display-name ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( onChange(e.target.value)} /> diff --git a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorExisting.test.tsx similarity index 95% rename from public/app/features/alerting/unified/RuleEditorExisting.test.tsx rename to public/app/features/alerting/unified/rule-editor/RuleEditorExisting.test.tsx index 15ecae4e3ec..1781ab75d2b 100644 --- a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorExisting.test.tsx @@ -7,15 +7,15 @@ import { setFolderResponse } from 'app/features/alerting/unified/mocks/server/co import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants'; import { captureRequests } from 'app/features/alerting/unified/mocks/server/events'; import { DashboardSearchItemType } from 'app/features/search/types'; +import { AccessControlAction } from 'app/types'; -import { AccessControlAction } from '../../../types'; +import { setupMswServer } from '../mockApi'; +import { grantUserPermissions, mockDataSource, mockFolder } from '../mocks'; +import { grafanaRulerRule } from '../mocks/grafanaRulerApi'; +import { setupDataSources } from '../testSetup/datasources'; +import { Annotation } from '../utils/constants'; import RuleEditor from './RuleEditor'; -import { setupMswServer } from './mockApi'; -import { grantUserPermissions, mockDataSource, mockFolder } from './mocks'; -import { grafanaRulerRule } from './mocks/grafanaRulerApi'; -import { setupDataSources } from './testSetup/datasources'; -import { Annotation } from './utils/constants'; jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
{actions}
, diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx similarity index 94% rename from public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx rename to public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx index e07f62cd7c1..94888d4c9b1 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx @@ -9,10 +9,10 @@ import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { PROMETHEUS_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants'; import { AccessControlAction } from 'app/types'; -import { grantUserPermissions, mockDataSource } from './mocks'; -import { grafanaRulerGroup } from './mocks/grafanaRulerApi'; -import { captureRequests, serializeRequests } from './mocks/server/events'; -import { setupDataSources } from './testSetup/datasources'; +import { grantUserPermissions, mockDataSource } from '../mocks'; +import { grafanaRulerGroup } from '../mocks/grafanaRulerApi'; +import { captureRequests, serializeRequests } from '../mocks/server/events'; +import { setupDataSources } from '../testSetup/datasources'; jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
{actions}
, diff --git a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorRecordingRule.test.tsx similarity index 88% rename from public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx rename to public/app/features/alerting/unified/rule-editor/RuleEditorRecordingRule.test.tsx index 766253febf1..8e979828aa9 100644 --- a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorRecordingRule.test.tsx @@ -7,14 +7,14 @@ import { byText } from 'testing-library-selector'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { AccessControlAction } from 'app/types'; -import { RecordingRuleEditorProps } from './components/rule-editor/RecordingRuleEditor'; -import { grantUserPermissions } from './mocks'; -import { GROUP_3, NAMESPACE_2 } from './mocks/mimirRulerApi'; -import { mimirDataSource } from './mocks/server/configure'; -import { MIMIR_DATASOURCE_UID } from './mocks/server/constants'; -import { captureRequests, serializeRequests } from './mocks/server/events'; +import { RecordingRuleEditorProps } from '../components/rule-editor/RecordingRuleEditor'; +import { grantUserPermissions } from '../mocks'; +import { GROUP_3, NAMESPACE_2 } from '../mocks/mimirRulerApi'; +import { mimirDataSource } from '../mocks/server/configure'; +import { MIMIR_DATASOURCE_UID } from '../mocks/server/constants'; +import { captureRequests, serializeRequests } from '../mocks/server/events'; -jest.mock('./components/rule-editor/RecordingRuleEditor', () => ({ +jest.mock('../components/rule-editor/RecordingRuleEditor', () => ({ RecordingRuleEditor: ({ queries, onChangeQuery }: Pick) => { const onChange = (expr: string) => { const query = queries[0]; diff --git a/public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap b/public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorCloudRules.test.tsx.snap similarity index 100% rename from public/app/features/alerting/unified/__snapshots__/RuleEditorCloudRules.test.tsx.snap rename to public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorCloudRules.test.tsx.snap diff --git a/public/app/features/alerting/unified/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap b/public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap similarity index 100% rename from public/app/features/alerting/unified/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap rename to public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap diff --git a/public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap b/public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorRecordingRule.test.tsx.snap similarity index 100% rename from public/app/features/alerting/unified/__snapshots__/RuleEditorRecordingRule.test.tsx.snap rename to public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorRecordingRule.test.tsx.snap diff --git a/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts b/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts new file mode 100644 index 00000000000..800b95a6193 --- /dev/null +++ b/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts @@ -0,0 +1,196 @@ +import { config } from '@grafana/runtime'; + +import { mockAlertQuery, mockDataSource, reduceExpression, thresholdExpression } from '../mocks'; +import { testWithFeatureToggles } from '../test/test-utils'; +import { RuleFormType } from '../types/rule-form'; +import { Annotation } from '../utils/constants'; +import { DataSourceType, getDefaultOrFirstCompatibleDataSource } from '../utils/datasource'; +import { MANUAL_ROUTING_KEY, getDefaultQueries } from '../utils/rule-form'; + +import { formValuesFromQueryParams, getDefaultFormValues, getDefautManualRouting } from './formDefaults'; +import { isAlertQueryOfAlertData } from './formProcessing'; + +jest.mock('../utils/datasource'); + +const mocks = { + getDefaultOrFirstCompatibleDataSource: jest.mocked(getDefaultOrFirstCompatibleDataSource), +}; + +// Setup mock implementation +mocks.getDefaultOrFirstCompatibleDataSource.mockReturnValue( + mockDataSource({ + type: DataSourceType.Prometheus, + }) +); + +// TODO Not sure why queries are an empty array in the default form values +const defaultFormValues = { + ...getDefaultFormValues(), + queries: getDefaultQueries(), +}; + +describe('formValuesFromQueryParams', () => { + it('should return default values when given invalid JSON', () => { + const result = formValuesFromQueryParams('invalid json', RuleFormType.grafana); + + expect(result).toEqual(defaultFormValues); + }); + + it('should normalize annotations', () => { + const ruleDefinition = JSON.stringify({ + annotations: [ + { key: 'custom', value: 'my custom annotation' }, + { key: Annotation.runbookURL, value: 'runbook annotation' }, + { key: 'custom-2', value: 'custom annotation v2' }, + { key: Annotation.summary, value: 'summary annotation' }, + { key: 'custom-3', value: 'custom annotation v3' }, + { key: Annotation.description, value: 'description annotation' }, + ], + }); + + const result = formValuesFromQueryParams(ruleDefinition, RuleFormType.grafana); + + const [summary, description, runbookURL, ...rest] = result.annotations; + + expect(summary).toEqual({ key: Annotation.summary, value: 'summary annotation' }); + expect(description).toEqual({ key: Annotation.description, value: 'description annotation' }); + expect(runbookURL).toEqual({ key: Annotation.runbookURL, value: 'runbook annotation' }); + expect(rest).toContainEqual({ key: 'custom', value: 'my custom annotation' }); + expect(rest).toContainEqual({ key: 'custom-2', value: 'custom annotation v2' }); + expect(rest).toContainEqual({ key: 'custom-3', value: 'custom annotation v3' }); + }); + + it('should disable simplified query editor when query switch mode is disabled', () => { + const result = formValuesFromQueryParams(JSON.stringify({}), RuleFormType.grafana); + + expect(result.editorSettings).toBeDefined(); + expect(result.editorSettings!.simplifiedQueryEditor).toBe(false); + }); + + describe('when simplified query editor is enabled', () => { + testWithFeatureToggles(['alertingQueryAndExpressionsStepMode']); + + it('should enable simplified query editor if queries are transformable to simple condition', () => { + const result = formValuesFromQueryParams( + JSON.stringify({ + queries: [mockAlertQuery(), reduceExpression, thresholdExpression], + }), + RuleFormType.grafana + ); + + expect(result.editorSettings).toBeDefined(); + expect(result.editorSettings!.simplifiedQueryEditor).toBe(true); + }); + + it('should disable simplified query editor if queries are not transformable to simple condition', () => { + const result = formValuesFromQueryParams( + JSON.stringify({ + queries: [mockAlertQuery(), mockAlertQuery(), thresholdExpression], + }), + RuleFormType.grafana + ); + + expect(result.editorSettings).toBeDefined(); + expect(result.editorSettings!.simplifiedQueryEditor).toBe(false); + }); + }); + + it('should default to instant queries for loki and prometheus if not specified', () => { + const result = formValuesFromQueryParams( + JSON.stringify({ + queries: [ + mockAlertQuery({ datasourceUid: 'loki', model: { refId: 'A', datasource: { type: DataSourceType.Loki } } }), + mockAlertQuery({ + datasourceUid: 'prometheus', + model: { refId: 'B', datasource: { type: DataSourceType.Prometheus } }, + }), + ], + }), + RuleFormType.grafana + ); + + const [lokiQuery, prometheusQuery] = result.queries.filter(isAlertQueryOfAlertData); + + expect(lokiQuery.model.instant).toBe(true); + expect(lokiQuery.model.range).toBe(false); + expect(prometheusQuery.model.instant).toBe(true); + expect(prometheusQuery.model.range).toBe(false); + }); + + it('should preserver instant and range values if specified', () => { + const result = formValuesFromQueryParams( + JSON.stringify({ + queries: [ + mockAlertQuery({ + datasourceUid: 'loki', + model: { refId: 'A', datasource: { type: DataSourceType.Loki }, instant: true, range: false }, + }), + mockAlertQuery({ + datasourceUid: 'prometheus', + model: { refId: 'B', datasource: { type: DataSourceType.Prometheus }, instant: false, range: true }, + }), + ], + }), + RuleFormType.grafana + ); + + const [lokiQuery, prometheusQuery] = result.queries.filter(isAlertQueryOfAlertData); + + expect(lokiQuery.model.instant).toBe(true); + expect(lokiQuery.model.range).toBe(false); + expect(prometheusQuery.model.range).toBe(true); + expect(prometheusQuery.model.instant).toBe(false); + }); + + it('should reveal hidden queries', () => { + const ruleDefinition = JSON.stringify({ + queries: [ + { refId: 'A', model: { refId: 'A', hide: true } }, + { refId: 'B', model: { refId: 'B', hide: false } }, + { refId: 'C', model: { refId: 'C' } }, + ], + }); + + const result = formValuesFromQueryParams(ruleDefinition, RuleFormType.grafana); + + expect(result.queries.length).toBe(3); + + const [q1, q2, q3] = result.queries; + expect(q1.refId).toBe('A'); + expect(q2.refId).toBe('B'); + expect(q3.refId).toBe('C'); + expect(q1.model).not.toHaveProperty('hide'); + expect(q2.model).not.toHaveProperty('hide'); + expect(q3.model).not.toHaveProperty('hide'); + }); +}); + +describe('getDefaultManualRouting', () => { + afterEach(() => { + window.localStorage.clear(); + }); + + it('returns false if the feature toggle is not enabled', () => { + config.featureToggles.alertingSimplifiedRouting = false; + expect(getDefautManualRouting()).toBe(false); + }); + + it('returns true if the feature toggle is enabled and localStorage is not set', () => { + config.featureToggles.alertingSimplifiedRouting = true; + expect(getDefautManualRouting()).toBe(true); + }); + + it('returns false if the feature toggle is enabled and localStorage is set to "false"', () => { + config.featureToggles.alertingSimplifiedRouting = true; + localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); + expect(getDefautManualRouting()).toBe(false); + }); + + it('returns true if the feature toggle is enabled and localStorage is set to any value other than "false"', () => { + config.featureToggles.alertingSimplifiedRouting = true; + localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); + expect(getDefautManualRouting()).toBe(true); + localStorage.removeItem(MANUAL_ROUTING_KEY); + expect(getDefautManualRouting()).toBe(true); + }); +}); diff --git a/public/app/features/alerting/unified/rule-editor/formDefaults.ts b/public/app/features/alerting/unified/rule-editor/formDefaults.ts new file mode 100644 index 00000000000..7c6a0c824c0 --- /dev/null +++ b/public/app/features/alerting/unified/rule-editor/formDefaults.ts @@ -0,0 +1,157 @@ +import { clamp } from 'lodash'; + +import { config } from '@grafana/runtime'; +import { RuleWithLocation } from 'app/types/unified-alerting'; +import { GrafanaAlertStateDecision, RulerRuleDTO } from 'app/types/unified-alerting-dto'; + +import { RuleFormType, RuleFormValues } from '../types/rule-form'; +// TODO Ideally all of these should be moved here +import { getRulesAccess } from '../utils/access-control'; +import { defaultAnnotations } from '../utils/constants'; +import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import { + MANUAL_ROUTING_KEY, + SIMPLIFIED_QUERY_EDITOR_KEY, + getDefaultQueries, + rulerRuleToFormValues, +} from '../utils/rule-form'; +import { isGrafanaRecordingRuleByType } from '../utils/rules'; +import { formatPrometheusDuration, safeParsePrometheusDuration } from '../utils/time'; + +import { + normalizeDefaultAnnotations, + revealHiddenQueries, + setInstantOrRange, + setQueryEditorSettings, +} from './formProcessing'; + +// even if the min interval is < 1m we should default to 1m, but allow arbitrary values for minInterval > 1m +const GROUP_EVALUATION_MIN_INTERVAL_MS = safeParsePrometheusDuration(config.unifiedAlerting?.minInterval ?? '10s'); +const GROUP_EVALUATION_INTERVAL_LOWER_BOUND = safeParsePrometheusDuration('1m'); +const GROUP_EVALUATION_INTERVAL_UPPER_BOUND = Infinity; + +export const DEFAULT_GROUP_EVALUATION_INTERVAL = formatPrometheusDuration( + clamp(GROUP_EVALUATION_MIN_INTERVAL_MS, GROUP_EVALUATION_INTERVAL_LOWER_BOUND, GROUP_EVALUATION_INTERVAL_UPPER_BOUND) +); +export const getDefaultFormValues = (): RuleFormValues => { + const { canCreateGrafanaRules, canCreateCloudRules } = getRulesAccess(); + + return Object.freeze({ + name: '', + uid: '', + labels: [{ key: '', value: '' }], + annotations: defaultAnnotations, + dataSourceName: GRAFANA_RULES_SOURCE_NAME, // let's use Grafana-managed alert rule by default + type: canCreateGrafanaRules ? RuleFormType.grafana : canCreateCloudRules ? RuleFormType.cloudAlerting : undefined, // viewers can't create prom alerts + group: '', + + // grafana + folder: undefined, + queries: [], + recordingRulesQueries: [], + condition: '', + noDataState: GrafanaAlertStateDecision.NoData, + execErrState: GrafanaAlertStateDecision.Error, + evaluateFor: DEFAULT_GROUP_EVALUATION_INTERVAL, + evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL, + manualRouting: getDefautManualRouting(), // we default to true if the feature toggle is enabled and the user hasn't set local storage to false + contactPoints: {}, + overrideGrouping: false, + overrideTimings: false, + muteTimeIntervals: [], + editorSettings: getDefaultEditorSettings(), + + // cortex / loki + namespace: '', + expression: '', + forTime: 1, + forTimeUnit: 'm', + }); +}; + +export const getDefautManualRouting = () => { + // first check if feature toggle for simplified routing is enabled + const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false; + if (!simplifiedRoutingToggleEnabled) { + return false; + } + //then, check in local storage if the user has enabled simplified routing + // if it's not set, we'll default to true + const manualRouting = localStorage.getItem(MANUAL_ROUTING_KEY); + return manualRouting !== 'false'; +}; + +function getDefaultEditorSettings() { + const editorSettingsEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false; + if (!editorSettingsEnabled) { + return undefined; + } + //then, check in local storage if the user has saved last rule with sections simplified + const queryEditorSettings = localStorage.getItem(SIMPLIFIED_QUERY_EDITOR_KEY); + const notificationStepSettings = localStorage.getItem(MANUAL_ROUTING_KEY); + return { + simplifiedQueryEditor: queryEditorSettings !== 'false', + simplifiedNotificationEditor: notificationStepSettings !== 'false', + }; +} + +export function formValuesFromQueryParams(ruleDefinition: string, type: RuleFormType): RuleFormValues { + let ruleFromQueryParams: Partial; + + try { + ruleFromQueryParams = JSON.parse(ruleDefinition); + } catch (err) { + return { + ...getDefaultFormValues(), + queries: getDefaultQueries(), + }; + } + + return setQueryEditorSettings( + setInstantOrRange( + revealHiddenQueries({ + ...getDefaultFormValues(), + ...ruleFromQueryParams, + annotations: normalizeDefaultAnnotations(ruleFromQueryParams.annotations ?? []), + queries: ruleFromQueryParams.queries ?? getDefaultQueries(), + type: type || RuleFormType.grafana, + evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL, + }) + ) + ); +} + +export function formValuesFromPrefill(rule: Partial): RuleFormValues { + return revealHiddenQueries({ + ...getDefaultFormValues(), + ...rule, + }); +} + +export function formValuesFromExistingRule(rule: RuleWithLocation) { + return revealHiddenQueries(rulerRuleToFormValues(rule)); +} + +export function defaultFormValuesForRuleType(ruleType: RuleFormType): RuleFormValues { + return { + ...getDefaultFormValues(), + condition: 'C', + queries: getDefaultQueries(isGrafanaRecordingRuleByType(ruleType)), + type: ruleType, + evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL, + }; +} + +// TODO This function is not 100% valid. There is no support for cloud form type because +// it's not valid from the path param point of view. +export function translateRouteParamToRuleType(param = ''): RuleFormType { + if (param === 'recording') { + return RuleFormType.cloudRecording; + } + + if (param === 'grafana-recording') { + return RuleFormType.grafanaRecording; + } + + return RuleFormType.grafana; +} diff --git a/public/app/features/alerting/unified/rule-editor/formProcessing.ts b/public/app/features/alerting/unified/rule-editor/formProcessing.ts new file mode 100644 index 00000000000..c9fa1951417 --- /dev/null +++ b/public/app/features/alerting/unified/rule-editor/formProcessing.ts @@ -0,0 +1,150 @@ +import { omit } from 'lodash'; + +import { config } from '@grafana/runtime'; +import { isExpressionQuery } from 'app/features/expressions/guards'; +import { ExpressionQuery, ExpressionQueryType, ReducerMode } from 'app/features/expressions/types'; +import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; + +import { SimpleConditionIdentifier } from '../components/rule-editor/query-and-alert-condition/SimpleCondition'; +import { KVObject, RuleFormValues } from '../types/rule-form'; +import { defaultAnnotations } from '../utils/constants'; +import { DataSourceType } from '../utils/datasource'; + +export function setQueryEditorSettings(values: RuleFormValues): RuleFormValues { + const isQuerySwitchModeEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false; + + if (!isQuerySwitchModeEnabled) { + return { + ...values, + editorSettings: { + simplifiedQueryEditor: false, + simplifiedNotificationEditor: true, // actually it doesn't matter in this case + }, + }; + } + + // data queries only + const dataQueries = values.queries.filter((query) => !isExpressionQuery(query.model)); + + // expression queries only + const expressionQueries = values.queries.filter((query) => isExpressionQueryInAlert(query)); + + const queryParamsAreTransformable = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries); + return { + ...values, + editorSettings: { + simplifiedQueryEditor: queryParamsAreTransformable, + simplifiedNotificationEditor: true, + }, + }; +} + +export function setInstantOrRange(values: RuleFormValues): RuleFormValues { + return { + ...values, + queries: values.queries?.map((query) => { + if (isExpressionQuery(query.model)) { + return query; + } + // data query + const defaultToInstant = + query.model.datasource?.type === DataSourceType.Loki || + query.model.datasource?.type === DataSourceType.Prometheus; + const isInstant = + 'instant' in query.model && query.model.instant !== undefined ? query.model.instant : defaultToInstant; + return { + ...query, + model: { + ...query.model, + instant: isInstant, + range: !isInstant, // we cannot have both instant and range queries in alerting + }, + }; + }), + }; +} + +export function areQueriesTransformableToSimpleCondition( + dataQueries: Array>, + expressionQueries: Array> +) { + if (dataQueries.length !== 1) { + return false; + } + const singleReduceExpressionInInstantQuery = + 'instant' in dataQueries[0].model && dataQueries[0].model.instant && expressionQueries.length === 1; + + if (expressionQueries.length !== 2 && !singleReduceExpressionInInstantQuery) { + return false; + } + + const query = dataQueries[0]; + + if (query.refId !== SimpleConditionIdentifier.queryId) { + return false; + } + + const reduceExpressionIndex = expressionQueries.findIndex( + (query) => query.model.type === ExpressionQueryType.reduce && query.refId === SimpleConditionIdentifier.reducerId + ); + const reduceExpression = expressionQueries.at(reduceExpressionIndex); + const reduceOk = + reduceExpression && + reduceExpressionIndex === 0 && + (reduceExpression.model.settings?.mode === ReducerMode.Strict || + reduceExpression.model.settings?.mode === undefined); + + const thresholdExpressionIndex = expressionQueries.findIndex( + (query) => + query.model.type === ExpressionQueryType.threshold && query.refId === SimpleConditionIdentifier.thresholdId + ); + const thresholdExpression = expressionQueries.at(thresholdExpressionIndex); + const conditions = thresholdExpression?.model.conditions ?? []; + const thresholdIndexOk = singleReduceExpressionInInstantQuery + ? thresholdExpressionIndex === 0 + : thresholdExpressionIndex === 1; + const thresholdOk = thresholdExpression && thresholdIndexOk && conditions[0]?.unloadEvaluator === undefined; + return (Boolean(reduceOk) || Boolean(singleReduceExpressionInInstantQuery)) && Boolean(thresholdOk); +} + +export function isExpressionQueryInAlert( + query: AlertQuery +): query is AlertQuery { + return isExpressionQuery(query.model); +} + +export function isAlertQueryOfAlertData( + query: AlertQuery +): query is AlertQuery { + return !isExpressionQuery(query.model); +} + +// the backend will always execute "hidden" queries, so we have no choice but to remove the property in the front-end +// to avoid confusion. The query editor shows them as "disabled" and that's a different semantic meaning. +// furthermore the "AlertingQueryRunner" calls `filterQuery` on each data source and those will skip running queries that are "hidden"." +// It seems like we have no choice but to act like "hidden" queries don't exist in alerting. +export const revealHiddenQueries = (ruleDefinition: RuleFormValues): RuleFormValues => { + return { + ...ruleDefinition, + queries: ruleDefinition.queries?.map((query) => omit(query, 'model.hide')), + }; +}; + +export function normalizeDefaultAnnotations(annotations: KVObject[]) { + const orderedAnnotations = [...annotations]; + const defaultAnnotationKeys = defaultAnnotations.map((annotation) => annotation.key); + + defaultAnnotationKeys.forEach((defaultAnnotationKey, index) => { + const fieldIndex = orderedAnnotations.findIndex((field) => field.key === defaultAnnotationKey); + + if (fieldIndex === -1) { + //add the default annotation if abstent + const emptyValue = { key: defaultAnnotationKey, value: '' }; + orderedAnnotations.splice(index, 0, emptyValue); + } else if (fieldIndex !== index) { + //move it to the correct position if present + orderedAnnotations.splice(index, 0, orderedAnnotations.splice(fieldIndex, 1)[0]); + } + }); + return orderedAnnotations; +} diff --git a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx index b436e84941a..de51e6dde8b 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx @@ -19,9 +19,9 @@ grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]); setupMswServer(); -const mimirGroups = alertingFactory.group.buildList(5000, { file: 'test-mimir-namespace' }); -alertingFactory.group.rewindSequence(); -const prometheusGroups = alertingFactory.group.buildList(200, { file: 'test-prometheus-namespace' }); +const mimirGroups = alertingFactory.prometheus.group.buildList(5000, { file: 'test-mimir-namespace' }); +alertingFactory.prometheus.group.rewindSequence(); +const prometheusGroups = alertingFactory.prometheus.group.buildList(200, { file: 'test-prometheus-namespace' }); const mimirDs = alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' }); const prometheusDs = alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' }); diff --git a/public/app/features/alerting/unified/rule-list/GroupedView.test.tsx b/public/app/features/alerting/unified/rule-list/GroupedView.test.tsx index adb8b83c077..953bce50ead 100644 --- a/public/app/features/alerting/unified/rule-list/GroupedView.test.tsx +++ b/public/app/features/alerting/unified/rule-list/GroupedView.test.tsx @@ -19,9 +19,9 @@ grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]); setupMswServer(); -const mimirGroups = alertingFactory.group.buildList(500, { file: 'test-mimir-namespace' }); -alertingFactory.group.rewindSequence(); -const prometheusGroups = alertingFactory.group.buildList(130, { file: 'test-prometheus-namespace' }); +const mimirGroups = alertingFactory.prometheus.group.buildList(500, { file: 'test-mimir-namespace' }); +alertingFactory.prometheus.group.rewindSequence(); +const prometheusGroups = alertingFactory.prometheus.group.buildList(130, { file: 'test-prometheus-namespace' }); const mimirDs = alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' }); const prometheusDs = alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' }); diff --git a/public/app/features/alerting/unified/test/test-utils.ts b/public/app/features/alerting/unified/test/test-utils.ts index 88de7e27984..66598dea083 100644 --- a/public/app/features/alerting/unified/test/test-utils.ts +++ b/public/app/features/alerting/unified/test/test-utils.ts @@ -2,6 +2,10 @@ import { act } from '@testing-library/react'; import { FeatureToggles } from '@grafana/data'; import { config } from '@grafana/runtime'; +import { AccessControlAction } from 'app/types'; + +import { grantUserPermissions } from '../mocks'; +import { setFolderAccessControl } from '../mocks/server/configure'; /** * Flushes out microtasks so we don't get warnings from `@floating-ui/react` @@ -47,3 +51,13 @@ export const testWithLicenseFeatures = (features: string[]) => { config.licenseInfo.enabledFeatures = originalFeatures; }); }; + +/** + * "Grants" permissions via contextSrv mock, and additionally sets folder access control + * API response to match + */ +export const grantPermissionsHelper = (permissions: AccessControlAction[]) => { + const permissionsHash = permissions.reduce((hash, permission) => ({ ...hash, [permission]: true }), {}); + grantUserPermissions(permissions); + setFolderAccessControl(permissionsHash); +}; diff --git a/public/app/features/alerting/unified/utils/rule-form.test.ts b/public/app/features/alerting/unified/utils/rule-form.test.ts index bfc7f86ea5c..80442e87984 100644 --- a/public/app/features/alerting/unified/utils/rule-form.test.ts +++ b/public/app/features/alerting/unified/utils/rule-form.test.ts @@ -1,20 +1,17 @@ import { PromQuery } from '@grafana/prometheus'; -import { config } from '@grafana/runtime'; import { GrafanaAlertStateDecision, GrafanaRuleDefinition, RulerAlertingRuleDTO } from 'app/types/unified-alerting-dto'; +import { getDefaultFormValues } from '../rule-editor/formDefaults'; import { AlertManagerManualRouting, RuleFormType, RuleFormValues } from '../types/rule-form'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { - MANUAL_ROUTING_KEY, alertingRulerRuleToRuleForm, cleanAnnotations, cleanLabels, formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO, getContactPointsFromDTO, - getDefaultFormValues, - getDefautManualRouting, getNotificationSettingsForDTO, } from './rule-form'; @@ -227,36 +224,6 @@ describe('getNotificationSettingsForDTO', () => { }); }); -describe('getDefautManualRouting', () => { - afterEach(() => { - window.localStorage.clear(); - }); - - it('returns false if the feature toggle is not enabled', () => { - config.featureToggles.alertingSimplifiedRouting = false; - expect(getDefautManualRouting()).toBe(false); - }); - - it('returns true if the feature toggle is enabled and localStorage is not set', () => { - config.featureToggles.alertingSimplifiedRouting = true; - expect(getDefautManualRouting()).toBe(true); - }); - - it('returns false if the feature toggle is enabled and localStorage is set to "false"', () => { - config.featureToggles.alertingSimplifiedRouting = true; - localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); - expect(getDefautManualRouting()).toBe(false); - }); - - it('returns true if the feature toggle is enabled and localStorage is set to any value other than "false"', () => { - config.featureToggles.alertingSimplifiedRouting = true; - localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); - expect(getDefautManualRouting()).toBe(true); - localStorage.removeItem(MANUAL_ROUTING_KEY); - expect(getDefautManualRouting()).toBe(true); - }); -}); - describe('cleanAnnotations', () => { it('should remove falsy KVs', () => { const output = cleanAnnotations([{ key: '', value: '' }]); diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index de5ee071296..ecea384f9e7 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -1,5 +1,3 @@ -import { clamp, omit } from 'lodash'; - import { DataQuery, DataSourceInstanceSettings, @@ -31,7 +29,6 @@ import { AlertDataQuery, AlertQuery, Annotations, - GrafanaAlertStateDecision, GrafanaNotificationSettings, GrafanaRuleDefinition, Labels, @@ -42,6 +39,8 @@ import { } from 'app/types/unified-alerting-dto'; import { EvalFunction } from '../../state/alertDef'; +import { getDefaultFormValues } from '../rule-editor/formDefaults'; +import { normalizeDefaultAnnotations } from '../rule-editor/formProcessing'; import { AlertManagerManualRouting, ContactPoint, @@ -51,8 +50,7 @@ import { SimplifiedEditor, } from '../types/rule-form'; -import { getRulesAccess } from './access-control'; -import { Annotation, defaultAnnotations } from './constants'; +import { Annotation } from './constants'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME, @@ -68,84 +66,13 @@ import { isGrafanaRulerRule, isRecordingRulerRule, } from './rules'; -import { formatPrometheusDuration, parseInterval, safeParsePrometheusDuration } from './time'; +import { parseInterval } from './time'; export type PromOrLokiQuery = PromQuery | LokiQuery; export const MANUAL_ROUTING_KEY = 'grafana.alerting.manualRouting'; export const SIMPLIFIED_QUERY_EDITOR_KEY = 'grafana.alerting.simplifiedQueryEditor'; -// even if the min interval is < 1m we should default to 1m, but allow arbitrary values for minInterval > 1m -const GROUP_EVALUATION_MIN_INTERVAL_MS = safeParsePrometheusDuration(config.unifiedAlerting?.minInterval ?? '10s'); -const GROUP_EVALUATION_INTERVAL_LOWER_BOUND = safeParsePrometheusDuration('1m'); -const GROUP_EVALUATION_INTERVAL_UPPER_BOUND = Infinity; - -export const DEFAULT_GROUP_EVALUATION_INTERVAL = formatPrometheusDuration( - clamp(GROUP_EVALUATION_MIN_INTERVAL_MS, GROUP_EVALUATION_INTERVAL_LOWER_BOUND, GROUP_EVALUATION_INTERVAL_UPPER_BOUND) -); - -export const getDefaultFormValues = (): RuleFormValues => { - const { canCreateGrafanaRules, canCreateCloudRules } = getRulesAccess(); - - return Object.freeze({ - name: '', - uid: '', - labels: [{ key: '', value: '' }], - annotations: defaultAnnotations, - dataSourceName: GRAFANA_RULES_SOURCE_NAME, // let's use Grafana-managed alert rule by default - type: canCreateGrafanaRules ? RuleFormType.grafana : canCreateCloudRules ? RuleFormType.cloudAlerting : undefined, // viewers can't create prom alerts - group: '', - - // grafana - folder: undefined, - queries: [], - recordingRulesQueries: [], - condition: '', - noDataState: GrafanaAlertStateDecision.NoData, - execErrState: GrafanaAlertStateDecision.Error, - evaluateFor: DEFAULT_GROUP_EVALUATION_INTERVAL, - evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL, - manualRouting: getDefautManualRouting(), // we default to true if the feature toggle is enabled and the user hasn't set local storage to false - contactPoints: {}, - overrideGrouping: false, - overrideTimings: false, - muteTimeIntervals: [], - editorSettings: getDefaultEditorSettings(), - - // cortex / loki - namespace: '', - expression: '', - forTime: 1, - forTimeUnit: 'm', - }); -}; - -export const getDefautManualRouting = () => { - // first check if feature toggle for simplified routing is enabled - const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false; - if (!simplifiedRoutingToggleEnabled) { - return false; - } - //then, check in local storage if the user has enabled simplified routing - // if it's not set, we'll default to true - const manualRouting = localStorage.getItem(MANUAL_ROUTING_KEY); - return manualRouting !== 'false'; -}; - -function getDefaultEditorSettings() { - const editorSettingsEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false; - if (!editorSettingsEnabled) { - return undefined; - } - //then, check in local storage if the user has saved last rule with sections simplified - const queryEditorSettings = localStorage.getItem(SIMPLIFIED_QUERY_EDITOR_KEY); - const notificationStepSettings = localStorage.getItem(MANUAL_ROUTING_KEY); - return { - simplifiedQueryEditor: queryEditorSettings !== 'false', - simplifiedNotificationEditor: notificationStepSettings !== 'false', - }; -} - export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO { const { name, expression, forTime, forTimeUnit, keepFiringForTime, keepFiringForTimeUnit, type } = values; @@ -184,26 +111,6 @@ export function listifyLabelsOrAnnotations(item: Labels | Annotations | undefine return list; } -//make sure default annotations are always shown in order even if empty -export function normalizeDefaultAnnotations(annotations: KVObject[]) { - const orderedAnnotations = [...annotations]; - const defaultAnnotationKeys = defaultAnnotations.map((annotation) => annotation.key); - - defaultAnnotationKeys.forEach((defaultAnnotationKey, index) => { - const fieldIndex = orderedAnnotations.findIndex((field) => field.key === defaultAnnotationKey); - - if (fieldIndex === -1) { - //add the default annotation if abstent - const emptyValue = { key: defaultAnnotationKey, value: '' }; - orderedAnnotations.splice(index, 0, emptyValue); - } else if (fieldIndex !== index) { - //move it to the correct position if present - orderedAnnotations.splice(index, 0, orderedAnnotations.splice(fieldIndex, 1)[0]); - } - }); - return orderedAnnotations; -} - export function getNotificationSettingsForDTO( manualRouting: boolean, contactPoints?: AlertManagerManualRouting @@ -902,21 +809,6 @@ export function isPromOrLokiQuery(model: AlertDataQuery): model is PromOrLokiQue return 'expr' in model; } -// the backend will always execute "hidden" queries, so we have no choice but to remove the property in the front-end -// to avoid confusion. The query editor shows them as "disabled" and that's a different semantic meaning. -// furthermore the "AlertingQueryRunner" calls `filterQuery` on each data source and those will skip running queries that are "hidden"." -// It seems like we have no choice but to act like "hidden" queries don't exist in alerting. -export const ignoreHiddenQueries = (ruleDefinition: RuleFormValues): RuleFormValues => { - return { - ...ruleDefinition, - queries: ruleDefinition.queries?.map((query) => omit(query, 'model.hide')), - }; -}; - -export function formValuesFromExistingRule(rule: RuleWithLocation) { - return ignoreHiddenQueries(rulerRuleToFormValues(rule)); -} - export function getInstantFromDataQuery(model: AlertDataQuery, type: string): boolean | undefined { // if the datasource is not prometheus or loki, instant is defined in the model or defaults to undefined if (type !== DataSourceType.Prometheus && type !== DataSourceType.Loki) { diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx index 7d93518167e..582e0855008 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx @@ -223,7 +223,7 @@ async function buildTestSceneWithLibraryPanel() { title: 'Panel A', pluginId: 'table', key: 'panel-12', - $behaviors: [new LibraryPanelBehavior({ title: 'LibraryPanel A title', name: 'LibraryPanel A', uid: '111' })], + $behaviors: [new LibraryPanelBehavior({ name: 'LibraryPanel A', uid: '111' })], titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })], $data: new SceneDataTransformer({ transformations: [ diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 70c468e9247..22b527b5bdc 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -2,6 +2,7 @@ import { isEqual } from 'lodash'; import { locationUtil, UrlQueryMap } from '@grafana/data'; import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; +import { sceneGraph } from '@grafana/scenes'; import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors'; @@ -136,7 +137,10 @@ abstract class DashboardScenePageStateManagerBase this.setState({ dashboard: dashboard, isLoading: false, options }); const measure = stopMeasure(LOAD_SCENE_MEASUREMENT); + const queryController = sceneGraph.getQueryController(dashboard); + trackDashboardSceneLoaded(dashboard, measure?.duration); + queryController?.startProfile(dashboard); if (options.route !== DashboardRoutes.New) { emitDashboardViewEvent({ diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts index fa309c8bd21..f204eae7138 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -200,7 +200,6 @@ describe('PanelEditor', () => { const libPanelBehavior = new LibraryPanelBehavior({ isLoaded: true, - title: libraryPanelModel.title, uid: libraryPanelModel.uid, name: libraryPanelModel.name, _loadedPanel: libraryPanelModel, @@ -239,7 +238,7 @@ describe('PanelEditor', () => { // Wait for mock api to return and update the library panel expect(libPanelBehavior.state._loadedPanel?.version).toBe(2); expect(libPanelBehavior.state.name).toBe('changed name'); - expect(libPanelBehavior.state.title).toBe('changed title'); + expect(panel.state.title).toBe('changed title'); expect((gridItem.state.body as VizPanel).state.title).toBe('changed title'); }); @@ -258,7 +257,6 @@ describe('PanelEditor', () => { const libPanelBehavior = new LibraryPanelBehavior({ isLoaded: true, - title: libraryPanelModel.title, uid: libraryPanelModel.uid, name: libraryPanelModel.name, _loadedPanel: libraryPanelModel, diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx index 264c053de17..3f54a519b70 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx @@ -177,7 +177,6 @@ describe('PanelOptions', () => { const libraryPanel = new LibraryPanelBehavior({ isLoaded: true, - title: libraryPanelModel.title, uid: libraryPanelModel.uid, name: libraryPanelModel.name, _loadedPanel: libraryPanelModel, diff --git a/public/app/features/dashboard-scene/scene/AddLibraryPanelDrawer.test.tsx b/public/app/features/dashboard-scene/scene/AddLibraryPanelDrawer.test.tsx index f298e7b19f9..cba0d0f9e01 100644 --- a/public/app/features/dashboard-scene/scene/AddLibraryPanelDrawer.test.tsx +++ b/public/app/features/dashboard-scene/scene/AddLibraryPanelDrawer.test.tsx @@ -32,6 +32,7 @@ describe('AddLibraryPanelWidget', () => { const panelInfo: LibraryPanel = { uid: 'uid', model: { + title: 'model title', type: 'timeseries', }, name: 'name', @@ -47,6 +48,8 @@ describe('AddLibraryPanelWidget', () => { expect(panels.length).toBe(1); expect(panel.state.$behaviors![0]).toBeInstanceOf(LibraryPanelBehavior); expect(panel.state.key).toBe('panel-1'); + expect(panel.state.title).toBe('model title'); + expect(panel.state.hoverHeader).toBe(false); }); it('should add library panel from menu and enter edit mode in a dashboard that is not already in edit mode', async () => { @@ -69,6 +72,7 @@ describe('AddLibraryPanelWidget', () => { const panelInfo: LibraryPanel = { uid: 'uid', model: { + title: 'model title', type: 'timeseries', }, name: 'name', @@ -88,15 +92,16 @@ describe('AddLibraryPanelWidget', () => { expect(panels.length).toBe(1); expect(panel.state.$behaviors![0]).toBeInstanceOf(LibraryPanelBehavior); expect(panel.state.key).toBe('panel-1'); + expect(panel.state.title).toBe('model title'); expect(dashboard.state.isEditing).toBe(true); }); it('should replace grid item when grid item state is passed', async () => { const libPanel = new VizPanel({ - title: 'Panel Title', + title: 'Some panel title', pluginId: 'table', key: 'panel-1', - $behaviors: [new LibraryPanelBehavior({ title: 'LibraryPanel A title', name: 'LibraryPanel A', uid: 'uid' })], + $behaviors: [new LibraryPanelBehavior({ name: 'LibraryPanel A', uid: 'uid' })], }); addLibPanelDrawer = new AddLibraryPanelDrawer({ panelToReplaceRef: libPanel.getRef() }); @@ -115,6 +120,7 @@ describe('AddLibraryPanelWidget', () => { const panelInfo: LibraryPanel = { uid: 'new_uid', model: { + title: 'model title', type: 'timeseries', }, name: 'new_name', @@ -132,6 +138,27 @@ describe('AddLibraryPanelWidget', () => { expect(behavior).toBeInstanceOf(LibraryPanelBehavior); expect(behavior.state.uid).toBe('new_uid'); expect(behavior.state.name).toBe('new_name'); + expect(panels[0].state.title).toBe('model title'); + }); + + it('should set hoverHeader to true if the library panel title is empty', () => { + const panelInfo: LibraryPanel = { + uid: 'uid', + model: { + title: '', + type: 'timeseries', + }, + name: 'name', + version: 1, + type: 'timeseries', + }; + + addLibPanelDrawer.onAddLibraryPanel(panelInfo); + + const panels = dashboard.state.body.getVizPanels(); + const panel = panels[0]; + expect(panel.state.title).toBe(''); + expect(panel.state.hoverHeader).toBe(true); }); }); diff --git a/public/app/features/dashboard-scene/scene/AddLibraryPanelDrawer.tsx b/public/app/features/dashboard-scene/scene/AddLibraryPanelDrawer.tsx index e8e462005eb..1d4c4b198a5 100644 --- a/public/app/features/dashboard-scene/scene/AddLibraryPanelDrawer.tsx +++ b/public/app/features/dashboard-scene/scene/AddLibraryPanelDrawer.tsx @@ -26,6 +26,9 @@ export class AddLibraryPanelDrawer extends SceneObjectBase { it('should re-run queries when library panel re-runs query', async () => { const libPanelBehavior = new LibraryPanelBehavior({ isLoaded: false, - title: 'Panel title', uid: 'fdcvggvfy2qdca', name: 'My Library Panel', _loadedPanel: undefined, @@ -469,7 +468,6 @@ describe('DashboardDatasourceBehaviour', () => { jest.spyOn(console, 'error').mockImplementation(); const libPanelBehavior = new LibraryPanelBehavior({ isLoaded: false, - title: 'Panel title', uid: 'fdcvggvfy2qdca', name: 'My Library Panel', _loadedPanel: undefined, @@ -519,7 +517,6 @@ describe('DashboardDatasourceBehaviour', () => { // Simulate library panel being loaded libPanelBehavior.setState({ isLoaded: true, - title: 'Panel title', uid: 'fdcvggvfy2qdca', name: 'My Library Panel', _loadedPanel: undefined, diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 2c54e174bea..80d4b19fa3a 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -468,7 +468,7 @@ describe('DashboardScene', () => { title: 'Library Panel', pluginId: 'table', key: 'panel-4', - $behaviors: [new LibraryPanelBehavior({ title: 'Library Panel', name: 'libraryPanel', uid: 'uid' })], + $behaviors: [new LibraryPanelBehavior({ name: 'libraryPanel', uid: 'uid' })], }); scene.copyPanel(libVizPanel); @@ -523,7 +523,7 @@ describe('DashboardScene', () => { title: 'Library Panel', pluginId: 'table', key: 'panel-4', - $behaviors: [new LibraryPanelBehavior({ title: 'Library Panel', name: 'libraryPanel', uid: 'uid' })], + $behaviors: [new LibraryPanelBehavior({ name: 'libraryPanel', uid: 'uid' })], }), }) ); @@ -542,7 +542,7 @@ describe('DashboardScene', () => { const libPanel = new VizPanel({ title: 'Panel B', pluginId: 'table', - $behaviors: [new LibraryPanelBehavior({ title: 'title', name: 'lib panel', uid: 'abc', isLoaded: true })], + $behaviors: [new LibraryPanelBehavior({ name: 'lib panel', uid: 'abc', isLoaded: true })], }); const scene = buildTestScene({ @@ -907,7 +907,7 @@ function buildTestScene(overrides?: Partial) { title: 'Library Panel', pluginId: 'table', key: 'panel-5', - $behaviors: [new LibraryPanelBehavior({ title: 'Library Panel', name: 'libraryPanel', uid: 'uid' })], + $behaviors: [new LibraryPanelBehavior({ name: 'libraryPanel', uid: 'uid' })], }), }), ], @@ -925,7 +925,7 @@ function buildTestScene(overrides?: Partial) { title: 'Library Panel', pluginId: 'table', key: 'panel-6', - $behaviors: [new LibraryPanelBehavior({ title: 'Library Panel', name: 'libraryPanel', uid: 'uid' })], + $behaviors: [new LibraryPanelBehavior({ name: 'libraryPanel', uid: 'uid' })], }), }), ], diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index a3c358b10a4..b285610b5a0 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -374,7 +374,13 @@ export class DashboardScene extends SceneObjectBase { } public onRestore = async (version: DecoratedRevisionModel): Promise => { - const versionRsp = await historySrv.restoreDashboard(version.uid, version.version); + let versionRsp; + if (config.featureToggles.kubernetesCliDashboards) { + // the id here is the resource version in k8s, use this instead to get the specific version + versionRsp = await historySrv.restoreDashboard(version.uid, version.id); + } else { + versionRsp = await historySrv.restoreDashboard(version.uid, version.version); + } if (!Number.isInteger(versionRsp.version)) { return false; diff --git a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.test.tsx b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.test.tsx index 02a363d5d3a..b6eb6872566 100644 --- a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.test.tsx @@ -167,7 +167,7 @@ describe('LibraryPanelBehavior', () => { }); async function buildTestSceneWithLibraryPanel() { - const behavior = new LibraryPanelBehavior({ title: 'LibraryPanel A title', name: 'LibraryPanel A', uid: '111' }); + const behavior = new LibraryPanelBehavior({ name: 'LibraryPanel A', uid: '111' }); const vizPanel = new VizPanel({ title: 'Panel A', diff --git a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx index 423cc2c34ee..44a1c0de5a8 100644 --- a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx @@ -17,8 +17,6 @@ import { AngularDeprecation } from './angular/AngularDeprecation'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; export interface LibraryPanelBehaviorState extends SceneObjectState { - // Library panels use title from dashboard JSON's panel model, not from library panel definition, hence we pass it. - title?: string; uid: string; name: string; isLoaded?: boolean; @@ -66,7 +64,7 @@ export class LibraryPanelBehavior extends SceneObjectBase uid === '*' || uid === oldModel.uid) !== -1, + onProfileComplete: getDashboardInteractionCallback(oldModel.uid, oldModel.title), + }), registerDashboardMacro, registerPanelInteractionsReporter, new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }), @@ -434,3 +439,40 @@ function trackIfEmpty(grid: SceneGridLayout) { sub.unsubscribe(); }; } + +function getDashboardInteractionCallback(uid: string, title: string) { + return (e: SceneInteractionProfileEvent) => { + let interactionType = ''; + + if (e.origin === 'SceneTimeRange') { + interactionType = 'time-range-change'; + } else if (e.origin === 'SceneRefreshPicker') { + interactionType = 'refresh'; + } else if (e.origin === 'DashboardScene') { + interactionType = 'view'; + } else if (e.origin.indexOf('Variable') > -1) { + interactionType = 'variable-change'; + } + reportInteraction('dashboard-render', { + interactionType, + duration: e.duration, + networkDuration: e.networkDuration, + totalJSHeapSize: e.totalJSHeapSize, + usedJSHeapSize: e.usedJSHeapSize, + jsHeapSizeLimit: e.jsHeapSizeLimit, + }); + + logMeasurement( + `dashboard.${interactionType}`, + { + duration: e.duration, + networkDuration: e.networkDuration, + totalJSHeapSize: e.totalJSHeapSize, + usedJSHeapSize: e.usedJSHeapSize, + jsHeapSizeLimit: e.jsHeapSizeLimit, + timeSinceBoot: performance.measure('time_since_boot', 'frontend_boot_js_done_time_seconds').duration, + }, + { dashboard: uid, title: title } + ); + }; +} diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts index 03f9d93fd94..a0fc2a828ec 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts @@ -355,7 +355,6 @@ describe('transformSceneToSaveModel', () => { $behaviors: [ new LibraryPanelBehavior({ name: 'Some lib panel panel', - title: 'A panel', uid: 'lib-panel-uid', }), ], @@ -399,7 +398,7 @@ describe('transformSceneToSaveModel', () => { x: 0, y: 0, }); - expect(result.title).toBe('A panel'); + expect(result.title).toBe('Panel blahh blah'); expect(result.transformations).toBeUndefined(); expect(result.fieldConfig).toBeUndefined(); expect(result.options).toBeUndefined(); @@ -851,7 +850,6 @@ describe('transformSceneToSaveModel', () => { $behaviors: [ new LibraryPanelBehavior({ name: 'Some lib panel panel', - title: 'A panel', uid: 'lib-panel-uid', }), ], @@ -865,7 +863,7 @@ describe('transformSceneToSaveModel', () => { expect(result[0]).toMatchObject({ id: 4, - title: 'A panel', + title: 'Panel blahh blah', libraryPanel: { name: 'Some lib panel panel', uid: 'lib-panel-uid', diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index 4c8368fc1fd..c1b6c96ec07 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -188,7 +188,7 @@ export function vizPanelToPanel( panel = { id: getPanelIdForVizPanel(vizPanel), - title: libPanel!.state.title, + title: vizPanel.state.title, gridPos: gridPos, libraryPanel: { name: libPanel!.state.name, diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx index 6d2b37219f6..e6449d0d3f6 100644 --- a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx @@ -112,44 +112,47 @@ describe('VersionsEditView', () => { }); 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: '', - checked: false, - }, - { - id: 3, - dashboardId: 1, - dashboardUID: '_U4zObQMz', - parentVersion: 1, - restoredFrom: 1, - version: 3, - created: '2017-02-22T17:43:01-08:00', - createdBy: 'admin', - message: '', - checked: false, - }, - { - id: 2, - dashboardId: 1, - dashboardUID: '_U4zObQMz', - parentVersion: 1, - restoredFrom: 1, - version: 2, - created: '2017-02-23T17:43:01-08:00', - createdBy: 'admin', - message: '', - checked: false, - }, - ]; + return { + continueToken: '', + versions: [ + { + id: 4, + dashboardId: 1, + dashboardUID: '_U4zObQMz', + parentVersion: 3, + restoredFrom: 0, + version: 4, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + checked: false, + }, + { + id: 3, + dashboardId: 1, + dashboardUID: '_U4zObQMz', + parentVersion: 1, + restoredFrom: 1, + version: 3, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + checked: false, + }, + { + id: 2, + dashboardId: 1, + dashboardUID: '_U4zObQMz', + parentVersion: 1, + restoredFrom: 1, + version: 2, + created: '2017-02-23T17:43:01-08:00', + createdBy: 'admin', + message: '', + checked: false, + }, + ], + }; } async function buildTestScene() { diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx index c2acbb9b7af..17256382d0b 100644 --- a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { PageLayoutType, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes'; import { Spinner, Stack } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; @@ -41,6 +42,7 @@ export class VersionsEditView extends SceneObjectBase imp public static Component = VersionsEditorSettingsListView; private _limit: number = VERSIONS_FETCH_LIMIT; private _start = 0; + private _continueToken = ''; constructor(state: VersionsEditViewState) { super({ @@ -102,14 +104,20 @@ export class VersionsEditView extends SceneObjectBase imp this.setState({ isAppending: append }); + const requestOptions = this._continueToken + ? { limit: this._limit, start: this._start, continueToken: this._continueToken } + : { limit: this._limit, start: this._start }; + historySrv - .getHistoryList(uid, { limit: this._limit, start: this._start }) + .getHistoryList(uid, requestOptions) .then((result) => { this.setState({ isLoading: false, - versions: [...(this.state.versions ?? []), ...this.decorateVersions(result)], + versions: [...(this.state.versions ?? []), ...this.decorateVersions(result.versions)], }); this._start += this._limit; + // Update the continueToken for the next request, if available + this._continueToken = result.continueToken ?? ''; }) .catch((err) => console.log(err)) .finally(() => this.setState({ isAppending: false })); @@ -127,9 +135,15 @@ export class VersionsEditView extends SceneObjectBase imp if (!this._dashboard.state.uid) { return; } - - const lhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, baseInfo.version); - const rhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, newInfo.version); + let lhs, rhs; + if (config.featureToggles.kubernetesCliDashboards) { + // the id here is the resource version in k8s, use this instead to get the specific version + lhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, baseInfo.id); + rhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, newInfo.id); + } else { + lhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, baseInfo.version); + rhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, newInfo.version); + } this.setState({ baseInfo, @@ -145,6 +159,7 @@ export class VersionsEditView extends SceneObjectBase imp }; public reset = () => { + this._continueToken = ''; this.setState({ baseInfo: undefined, diffData: { 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 index c2caee31a0e..84a737d5a1d 100644 --- a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts +++ b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts @@ -62,11 +62,11 @@ describe('historySrv', () => { describe('getDashboardVersion', () => { it('should return a version object for the given dashboard id and version', () => { - getMock.mockImplementation(() => Promise.resolve(versionsResponse[0])); + getMock.mockImplementation(() => Promise.resolve(versionsResponse.versions[0])); historySrv = new HistorySrv(); return historySrv.getDashboardVersion(dash.uid, 4).then((version) => { - expect(version).toEqual(versionsResponse[0]); + expect(version).toEqual(versionsResponse.versions[0]); }); }); diff --git a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts index 61312b8ad45..95cd468ec63 100644 --- a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts +++ b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts @@ -4,6 +4,7 @@ import { Dashboard } from '@grafana/schema'; export interface HistoryListOpts { limit: number; start: number; + continueToken?: string; } export interface RevisionsModel { 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 index 0de97c47a95..3fbefb31c92 100644 --- a/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts +++ b/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts @@ -1,51 +1,54 @@ 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: '', - }, - ]; + return { + continueToken: '', + versions: [ + { + 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) { diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/UpsertSnapshot.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/UpsertSnapshot.tsx index 5458db42ec1..57cd584afb1 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/UpsertSnapshot.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/share-snapshot/UpsertSnapshot.tsx @@ -5,8 +5,7 @@ import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { SceneObjectRef, VizPanel } from '@grafana/scenes'; import { Alert, Button, Divider, Field, RadioButtonGroup, Stack, Text, useStyles2 } from '@grafana/ui'; import { Input } from '@grafana/ui/src/components/Input/Input'; -import { t } from '@grafana/ui/src/utils/i18n'; -import { Trans } from 'app/core/internationalization'; +import { t, Trans } from 'app/core/internationalization'; import { getExpireOptions } from '../../ShareSnapshotTab'; diff --git a/public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.test.ts b/public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.test.ts index e6e44c7a583..02dcc897936 100644 --- a/public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.test.ts +++ b/public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.test.ts @@ -17,7 +17,6 @@ describe('PanelModelCompatibilityWrapper', () => { const libPanel = new LibraryPanelBehavior({ uid: 'a', name: 'aa', - title: 'a', }); panel.setState({ diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx index 4b0b91ef6e6..16f8d84f237 100644 --- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx @@ -66,7 +66,7 @@ describe('VersionSettings', () => { await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); const tableBodyRows = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row'); - expect(tableBodyRows.length).toBe(versions.length); + expect(tableBodyRows.length).toBe(versions.versions.length); const firstRow = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row')[0]; @@ -76,7 +76,11 @@ describe('VersionSettings', () => { test('does not render buttons if versions === 1', async () => { // @ts-ignore - historySrv.getHistoryList.mockResolvedValue(versions.slice(0, 1)); + historySrv.getHistoryList.mockResolvedValue({ + continueToken: versions.continueToken, + versions: versions.versions.slice(0, 1), + }); + setup(); expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); @@ -90,7 +94,11 @@ describe('VersionSettings', () => { test('does not render show more button if versions < VERSIONS_FETCH_LIMIT', async () => { // @ts-ignore - historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT - 5)); + historySrv.getHistoryList.mockResolvedValue({ + continueToken: versions.continueToken, + versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT - 5), + }); + setup(); expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); @@ -104,7 +112,11 @@ describe('VersionSettings', () => { test('renders buttons if versions >= VERSIONS_FETCH_LIMIT', async () => { // @ts-ignore - historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT)); + historySrv.getHistoryList.mockResolvedValue({ + continueToken: versions.continueToken, + versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT), + }); + setup(); expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); @@ -124,9 +136,17 @@ describe('VersionSettings', () => { test('clicking show more appends results to the table', async () => { historySrv.getHistoryList // @ts-ignore - .mockImplementationOnce(() => Promise.resolve(versions.slice(0, VERSIONS_FETCH_LIMIT))) - .mockImplementationOnce( - () => new Promise((resolve) => setTimeout(() => resolve(versions.slice(VERSIONS_FETCH_LIMIT)), 1000)) + .mockImplementationOnce(() => + Promise.resolve({ + continueToken: versions.continueToken, + versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT), + }) + ) + .mockImplementationOnce(() => + Promise.resolve({ + continueToken: versions.continueToken, + versions: versions.versions.slice(VERSIONS_FETCH_LIMIT), + }) ); setup(); @@ -146,13 +166,16 @@ describe('VersionSettings', () => { await waitFor(() => { expect(screen.queryByText(/Fetching more entries/i)).not.toBeInTheDocument(); - expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(versions.length); + expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(versions.versions.length); }); }); test('selecting two versions and clicking compare button should render compare view', async () => { // @ts-ignore - historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT)); + historySrv.getHistoryList.mockResolvedValue({ + continueToken: versions.continueToken, + versions: versions.versions.slice(0, VERSIONS_FETCH_LIMIT), + }); historySrv.getDashboardVersion // @ts-ignore .mockImplementationOnce(() => Promise.resolve(diffs.lhs)) diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx index c22f31c9437..30ccbe39708 100644 --- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx @@ -1,6 +1,7 @@ import { PureComponent } from 'react'; import * as React from 'react'; +import { config } from '@grafana/runtime'; import { Spinner, HorizontalGroup } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { @@ -38,11 +39,13 @@ export const VERSIONS_FETCH_LIMIT = 10; export class VersionsSettings extends PureComponent { limit: number; start: number; + continueToken: string; constructor(props: Props) { super(props); this.limit = VERSIONS_FETCH_LIMIT; this.start = 0; + this.continueToken = ''; this.state = { isAppending: true, isLoading: true, @@ -62,14 +65,20 @@ export class VersionsSettings extends PureComponent { getVersions = (append = false) => { this.setState({ isAppending: append }); + const requestOptions = this.continueToken + ? { limit: this.limit, start: this.start, continueToken: this.continueToken } + : { limit: this.limit, start: this.start }; + historySrv - .getHistoryList(this.props.dashboard.uid, { limit: this.limit, start: this.start }) + .getHistoryList(this.props.dashboard.uid, requestOptions) .then((res) => { this.setState({ isLoading: false, - versions: [...this.state.versions, ...this.decorateVersions(res)], + versions: [...(this.state.versions ?? []), ...this.decorateVersions(res.versions)], }); this.start += this.limit; + // Update the continueToken for the next request, if available + this.continueToken = res.continueToken ?? ''; }) .catch((err) => console.log(err)) .finally(() => this.setState({ isAppending: false })); @@ -84,8 +93,15 @@ export class VersionsSettings extends PureComponent { isLoading: true, }); - const lhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, baseInfo.version); - const rhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, newInfo.version); + let lhs, rhs; + if (config.featureToggles.kubernetesCliDashboards) { + // the id here is the resource version in k8s, use this instead to get the specific version + lhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, baseInfo.id); + rhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, newInfo.id); + } else { + lhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, baseInfo.version); + rhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, newInfo.version); + } this.setState({ baseInfo, @@ -121,6 +137,7 @@ export class VersionsSettings extends PureComponent { }; reset = () => { + this.continueToken = ''; this.setState({ baseInfo: undefined, diffData: { diff --git a/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts b/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts index 62f3cf4d876..3b3dc5629d8 100644 --- a/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts +++ b/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts @@ -1,126 +1,129 @@ -export const versions = [ - { - id: 249, - dashboardId: 74, - dashboardUID: '_U4zObQMz', - parentVersion: 10, - restoredFrom: 0, - version: 11, - created: '2021-01-15T14:44:44+01:00', - createdBy: 'admin', - message: 'testing changes...', - }, - { - id: 247, - dashboardId: 74, - dashboardUID: '_U4zObQMz', - parentVersion: 9, - restoredFrom: 0, - version: 10, - created: '2021-01-15T10:19:17+01:00', - createdBy: 'admin', - message: '', - }, - { - id: 246, - dashboardId: 74, - dashboardUID: '_U4zObQMz', - parentVersion: 8, - restoredFrom: 0, - version: 9, - created: '2021-01-15T10:18:12+01:00', - createdBy: 'admin', - message: '', - }, - { - id: 245, - dashboardId: 74, - dashboardUID: '_U4zObQMz', - parentVersion: 7, - restoredFrom: 0, - version: 8, - created: '2021-01-15T10:11:16+01:00', - createdBy: 'admin', - message: '', - }, - { - id: 239, - dashboardId: 74, - dashboardUID: '_U4zObQMz', - parentVersion: 6, - restoredFrom: 0, - version: 7, - created: '2021-01-14T15:14:25+01:00', - createdBy: 'admin', - message: '', - }, - { - id: 237, - dashboardId: 74, - dashboardUID: '_U4zObQMz', - parentVersion: 5, - restoredFrom: 0, - version: 6, - created: '2021-01-14T14:55:29+01:00', - createdBy: 'admin', - message: '', - }, - { - id: 236, - dashboardId: 74, - dashboardUID: '_U4zObQMz', - parentVersion: 4, - restoredFrom: 0, - version: 5, - created: '2021-01-14T14:28:01+01:00', - createdBy: 'admin', - message: '', - }, - { - id: 218, - dashboardId: 74, - dashboardUID: '_U4zObQMz', - parentVersion: 3, - restoredFrom: 0, - version: 4, - created: '2021-01-08T10:45:33+01:00', - createdBy: 'admin', - message: '', - }, - { - id: 217, - dashboardId: 74, - dashboardUID: '_U4zObQMz', - parentVersion: 2, - restoredFrom: 0, - version: 3, - created: '2021-01-05T15:41:33+01:00', - createdBy: 'admin', - message: '', - }, - { - id: 216, - dashboardId: 74, - dashboardUID: '_U4zObQMz', - parentVersion: 1, - restoredFrom: 0, - version: 2, - created: '2021-01-05T15:01:50+01:00', - createdBy: 'admin', - message: '', - }, - { - id: 215, - dashboardId: 74, - dashboardUID: '_U4zObQMz', - parentVersion: 1, - restoredFrom: 0, - version: 1, - created: '2021-01-05T14:59:15+01:00', - createdBy: 'admin', - message: '', - }, -]; +export const versions = { + continueToken: '', + versions: [ + { + id: 249, + dashboardId: 74, + dashboardUID: '_U4zObQMz', + parentVersion: 10, + restoredFrom: 0, + version: 11, + created: '2021-01-15T14:44:44+01:00', + createdBy: 'admin', + message: 'testing changes...', + }, + { + id: 247, + dashboardId: 74, + dashboardUID: '_U4zObQMz', + parentVersion: 9, + restoredFrom: 0, + version: 10, + created: '2021-01-15T10:19:17+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 246, + dashboardId: 74, + dashboardUID: '_U4zObQMz', + parentVersion: 8, + restoredFrom: 0, + version: 9, + created: '2021-01-15T10:18:12+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 245, + dashboardId: 74, + dashboardUID: '_U4zObQMz', + parentVersion: 7, + restoredFrom: 0, + version: 8, + created: '2021-01-15T10:11:16+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 239, + dashboardId: 74, + dashboardUID: '_U4zObQMz', + parentVersion: 6, + restoredFrom: 0, + version: 7, + created: '2021-01-14T15:14:25+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 237, + dashboardId: 74, + dashboardUID: '_U4zObQMz', + parentVersion: 5, + restoredFrom: 0, + version: 6, + created: '2021-01-14T14:55:29+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 236, + dashboardId: 74, + dashboardUID: '_U4zObQMz', + parentVersion: 4, + restoredFrom: 0, + version: 5, + created: '2021-01-14T14:28:01+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 218, + dashboardId: 74, + dashboardUID: '_U4zObQMz', + parentVersion: 3, + restoredFrom: 0, + version: 4, + created: '2021-01-08T10:45:33+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 217, + dashboardId: 74, + dashboardUID: '_U4zObQMz', + parentVersion: 2, + restoredFrom: 0, + version: 3, + created: '2021-01-05T15:41:33+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 216, + dashboardId: 74, + dashboardUID: '_U4zObQMz', + parentVersion: 1, + restoredFrom: 0, + version: 2, + created: '2021-01-05T15:01:50+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 215, + dashboardId: 74, + dashboardUID: '_U4zObQMz', + parentVersion: 1, + restoredFrom: 0, + version: 1, + created: '2021-01-05T14:59:15+01:00', + createdBy: 'admin', + message: '', + }, + ], +}; export const diffs = { lhs: { diff --git a/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx b/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx index 5a4451b4596..955d45388c4 100644 --- a/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx +++ b/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx @@ -5,12 +5,13 @@ import { ConfirmModal } from '@grafana/ui'; import { useDashboardRestore } from './useDashboardRestore'; export interface RevertDashboardModalProps { hideModal: () => void; + id: number; version: number; } -export const RevertDashboardModal = ({ hideModal, version }: RevertDashboardModalProps) => { +export const RevertDashboardModal = ({ hideModal, id, version }: RevertDashboardModalProps) => { // TODO: how should state.error be handled? - const { state, onRestoreDashboard } = useDashboardRestore(version); + const { state, onRestoreDashboard } = useDashboardRestore(id, version); useEffect(() => { if (!state.loading && state.value) { diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx index 2f23cd32e17..72294e80941 100644 --- a/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx @@ -43,6 +43,7 @@ export const VersionHistoryComparison = ({ baseInfo, newInfo, diffData, isNewLat icon="history" onClick={() => { showModal(RevertDashboardModal, { + id: baseInfo.id, version: baseInfo.version, hideModal, }); diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx index 2cd8514992a..5edd119e490 100644 --- a/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx @@ -60,6 +60,7 @@ export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsT icon="history" onClick={() => { showModal(RevertDashboardModal, { + id: version.id, version: version.version, hideModal, }); diff --git a/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx index 4ee340a8110..91fd0964aca 100644 --- a/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx +++ b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { useAsyncFn } from 'react-use'; import { locationUtil } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { useAppNotification } from 'app/core/copy/appNotification'; import { historySrv } from 'app/features/dashboard-scene/settings/version-history'; import { useSelector } from 'app/types'; @@ -16,9 +16,12 @@ const restoreDashboard = async (version: number, dashboard: DashboardModel) => { return await historySrv.restoreDashboard(dashboard.uid, version); }; -export const useDashboardRestore = (version: number) => { +export const useDashboardRestore = (id: number, version: number) => { const dashboard = useSelector((state) => state.dashboard.getModel()); - const [state, onRestoreDashboard] = useAsyncFn(async () => await restoreDashboard(version, dashboard!), []); + const [state, onRestoreDashboard] = useAsyncFn( + async () => await restoreDashboard(config.featureToggles.kubernetesCliDashboards ? id : version, dashboard!), + [] + ); const notifyApp = useAppNotification(); useEffect(() => { diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index 7761ed37fbc..b8261ec1d32 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -15,9 +15,9 @@ import { } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { sceneGraph } from '@grafana/scenes'; -import { t } from '@grafana/ui/src/utils/i18n'; import appEvents from 'app/core/app_events'; import { config } from 'app/core/config'; +import { t } from 'app/core/internationalization'; import { AutoRefreshInterval, contextSrv, ContextSrv } from 'app/core/services/context_srv'; import { getCopiedTimeRange, getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; import { getTimeRange } from 'app/features/dashboard/utils/timeRange'; diff --git a/public/app/features/explore/ExploreRunQueryButton.tsx b/public/app/features/explore/ExploreRunQueryButton.tsx index 7dd87dbd6c3..7cb37a62a45 100644 --- a/public/app/features/explore/ExploreRunQueryButton.tsx +++ b/public/app/features/explore/ExploreRunQueryButton.tsx @@ -4,7 +4,7 @@ import { ConnectedProps, connect } from 'react-redux'; import { config, reportInteraction } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { Button, ButtonVariant, Dropdown, Menu, ToolbarButton } from '@grafana/ui'; -import { t } from '@grafana/ui/src/utils/i18n'; +import { t } from 'app/core/internationalization'; import { useSelector } from 'app/types'; import { changeDatasource } from './state/datasource'; @@ -28,7 +28,7 @@ interface ExploreRunQueryButtonProps { export type Props = ConnectedProps & ExploreRunQueryButtonProps; -/* +/* This component does not validate datasources before running them. Root datasource validation should happen outside this component and can pass in an undefined if invalid If query level validation is done and a query datasource is invalid, pass in disabled = true */ diff --git a/public/app/features/explore/state/time.ts b/public/app/features/explore/state/time.ts index 38683f45bf4..f794042c113 100644 --- a/public/app/features/explore/state/time.ts +++ b/public/app/features/explore/state/time.ts @@ -10,8 +10,8 @@ import { } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; import { RefreshPicker } from '@grafana/ui'; -import { t } from '@grafana/ui/src/utils/i18n'; import appEvents from 'app/core/app_events'; +import { t } from 'app/core/internationalization'; import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/core/utils/explore'; import { getCopiedTimeRange, getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; diff --git a/public/app/features/search/service/unified.test.ts b/public/app/features/search/service/unified.test.ts new file mode 100644 index 00000000000..4d50e25193b --- /dev/null +++ b/public/app/features/search/service/unified.test.ts @@ -0,0 +1,65 @@ +import { toDashboardResults, SearchHit, SearchAPIResponse } from './unified'; + +describe('Unified Storage Searcher', () => { + it('can create dashboard search results and set meta sortBy so column is added for sprinkles sort field', () => { + const mockHits: SearchHit[] = [ + { + resource: 'dashboard', + name: 'Main Dashboard', + title: 'Main Dashboard Title', + location: '/dashboards/1', + folder: 'General', + tags: ['monitoring', 'performance'], + field: { errors_today: 1 }, + url: '/dashboards/1', + }, + { + resource: 'dashboard', + name: 'Main Dashboard', + title: 'Main Dashboard Title', + location: '/dashboards/1', + folder: 'General', + tags: ['monitoring', 'performance'], + field: { errors_today: 2 }, + url: '/dashboards/1', + }, + ]; + + const mockResponse: SearchAPIResponse = { + totalHits: 2, + hits: mockHits, + facets: {}, + }; + const results = toDashboardResults(mockResponse, 'errors_today'); + + expect(results.length).toBe(2); + const sprinklesField = results.fields[10]; + expect(sprinklesField.name).toBe('errors_today'); + expect(sprinklesField.values).toEqual([1, 2]); // this also tests the hits original order is preserved + expect(results.meta?.custom?.sortBy).toBe('errors_today'); + }); + + it('will trim "-" from the sort field name', () => { + const mockHits: SearchHit[] = [ + { + resource: 'dashboard', + name: 'Main Dashboard', + title: 'Main Dashboard Title', + location: '/dashboards/1', + folder: 'General', + tags: ['monitoring', 'performance'], + field: { errors_today: 1 }, + url: '/dashboards/1', + }, + ]; + + const mockResponse: SearchAPIResponse = { + totalHits: 0, + hits: mockHits, + facets: {}, + }; + const results = toDashboardResults(mockResponse, '-errors_today'); + + expect(results.meta?.custom?.sortBy).toBe('errors_today'); + }); +}); diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 9e379873cc3..3ed44146c35 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -20,7 +20,7 @@ const loadingFrameName = 'Loading'; const searchURI = `apis/dashboard.grafana.app/v0alpha1/namespaces/${config.namespace}/search`; -type SearchHit = { +export type SearchHit = { resource: string; // dashboards | folders name: string; title: string; @@ -28,11 +28,13 @@ type SearchHit = { folder: string; tags: string[]; + field: Record; // extra fields from the backend - sort fields included here as well + // calculated in the frontend url: string; }; -type SearchAPIResponse = { +export type SearchAPIResponse = { totalHits: number; hits: SearchHit[]; facets?: { @@ -110,7 +112,7 @@ export class UnifiedSearcher implements GrafanaSearcher { const uri = await this.newRequest(query); const rsp = await getBackendSrv().get(uri); - const first = toDashboardResults(rsp); + const first = toDashboardResults(rsp, query.sort ?? ''); if (first.name === loadingFrameName) { return this.fallbackSearcher.search(query); } @@ -145,7 +147,7 @@ export class UnifiedSearcher implements GrafanaSearcher { } const nextPageUrl = `${uri}&offset=${offset}`; const resp = await getBackendSrv().get(nextPageUrl); - const frame = toDashboardResults(resp); + const frame = toDashboardResults(resp, query.sort ?? ''); if (!frame) { console.log('no results', frame); return; @@ -217,6 +219,9 @@ export class UnifiedSearcher implements GrafanaSearcher { if (query.sort) { const sort = query.sort.replace('_sort', '').replace('name', 'title'); uri += `&sort=${sort}`; + const sortField = sort.startsWith('-') ? sort.substring(1) : sort; + + uri += `&field=${sortField}`; // we want to the sort field to be included in the response } if (query.name?.length) { @@ -279,7 +284,7 @@ function getSortFieldDisplayName(name: string) { return name; } -function toDashboardResults(rsp: SearchAPIResponse): DataFrame { +export function toDashboardResults(rsp: SearchAPIResponse, sort: string): DataFrame { const hits = rsp.hits; if (hits.length < 1) { return { fields: [], length: 0 }; @@ -290,6 +295,11 @@ function toDashboardResults(rsp: SearchAPIResponse): DataFrame { location = 'general'; } + // display null field values as "-" + const field = Object.fromEntries( + Object.entries(hit.field ?? {}).map(([key, value]) => [key, value == null ? '-' : value]) + ); + return { ...hit, uid: hit.name, @@ -299,6 +309,7 @@ function toDashboardResults(rsp: SearchAPIResponse): DataFrame { location, name: hit.title, // 🤯 FIXME hit.name is k8s name, eg grafana dashboards UID kind: hit.resource.substring(0, hit.resource.length - 1), // dashboard "kind" is not plural + ...field, }; }); const frame = toDataFrame(dashboardHits); @@ -308,6 +319,11 @@ function toDashboardResults(rsp: SearchAPIResponse): DataFrame { max_score: 1, }, }; + if (sort && frame.meta.custom) { + // trim the "-" from sort if it exists + frame.meta.custom.sortBy = sort.startsWith('-') ? sort.substring(1) : sort; + } + for (const field of frame.fields) { field.display = getDisplayProcessor({ field, theme: config.theme2 }); } diff --git a/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx b/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx index fa24596b328..c9816d0fb1a 100644 --- a/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx @@ -3,13 +3,13 @@ import { FormProvider, useForm } from 'react-hook-form'; import { config, getBackendSrv, locationService } from '@grafana/runtime'; import { Button, Input, Field, FieldSet } from '@grafana/ui'; -import { t, Trans } from '@grafana/ui/src/utils/i18n'; import { Form } from 'app/core/components/Form/Form'; import { Page } from 'app/core/components/Page/Page'; import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; import { fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api'; import { RolePickerSelect } from 'app/core/components/RolePickerDrawer/RolePickerSelect'; import { contextSrv } from 'app/core/core'; +import { t, Trans } from 'app/core/internationalization'; import { AccessControlAction, OrgRole, Role, ServiceAccountCreateApiResponse, ServiceAccountDTO } from 'app/types'; import { OrgRolePicker } from '../admin/OrgRolePicker'; diff --git a/public/app/features/trails/DataTrailSettings.tsx b/public/app/features/trails/DataTrailSettings.tsx index 2584b7bb930..7007b511ad0 100644 --- a/public/app/features/trails/DataTrailSettings.tsx +++ b/public/app/features/trails/DataTrailSettings.tsx @@ -3,7 +3,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import { Dropdown, Switch, ToolbarButton, useStyles2 } from '@grafana/ui'; -import { Trans } from '@grafana/ui/src/utils/i18n'; +import { Trans } from 'app/core/internationalization'; import { MetricScene } from './MetricScene'; import { MetricSelectScene } from './MetricSelect/MetricSelectScene'; diff --git a/public/app/features/trails/MetricSelect/NativeHistogramBadge.tsx b/public/app/features/trails/MetricSelect/NativeHistogramBadge.tsx index f2c551f3d70..21fed5c0ce7 100644 --- a/public/app/features/trails/MetricSelect/NativeHistogramBadge.tsx +++ b/public/app/features/trails/MetricSelect/NativeHistogramBadge.tsx @@ -3,7 +3,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { SceneObjectBase } from '@grafana/scenes'; import { Badge, useStyles2 } from '@grafana/ui'; -import { Trans } from '@grafana/ui/src/utils/i18n'; +import { Trans } from 'app/core/internationalization'; export class NativeHistogramBadge extends SceneObjectBase { public static Component = () => { diff --git a/public/app/features/trails/banners/NativeHistogramBanner.tsx b/public/app/features/trails/banners/NativeHistogramBanner.tsx index 5813c06d30f..68ca35c0c67 100644 --- a/public/app/features/trails/banners/NativeHistogramBanner.tsx +++ b/public/app/features/trails/banners/NativeHistogramBanner.tsx @@ -3,7 +3,7 @@ import { useState, type Dispatch, type SetStateAction } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2, useTheme2, Alert, Button } from '@grafana/ui'; -import { t, Trans } from '@grafana/ui/src/utils/i18n'; +import { t, Trans } from 'app/core/internationalization'; import { DataTrail } from '../DataTrail'; import { reportExploreMetrics } from '../interactions'; diff --git a/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/ArgQueryEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/ArgQueryEditor.tsx index a06b3739072..4c326a44fb3 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/ArgQueryEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ArgQueryEditor/ArgQueryEditor.tsx @@ -1,7 +1,7 @@ import { intersection } from 'lodash'; import { useState, useMemo } from 'react'; -import { EditorFieldGroup, EditorRow, EditorRows } from '@grafana/experimental'; +import { EditorFieldGroup, EditorRow, EditorRows } from '@grafana/plugin-ui'; import Datasource from '../../datasource'; import { selectors } from '../../e2e/selectors'; diff --git a/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/AzureCredentialsForm.tsx b/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/AzureCredentialsForm.tsx index fa80caadc82..6ab8bc69a18 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/AzureCredentialsForm.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/AzureCredentialsForm.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { AzureAuthType, AzureCredentials, getAzureClouds } from '@grafana/azure-sdk'; import { SelectableValue } from '@grafana/data'; -import { ConfigSection } from '@grafana/experimental'; +import { ConfigSection } from '@grafana/plugin-ui'; import { Select, Field } from '@grafana/ui'; import { selectors } from '../../e2e/selectors'; diff --git a/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/ConfigEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/ConfigEditor.tsx index e4246af0996..bdf2129e11d 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/ConfigEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/ConfigEditor.tsx @@ -1,7 +1,7 @@ import { PureComponent } from 'react'; import { DataSourcePluginOptionsEditorProps, SelectableValue, updateDatasourcePluginOption } from '@grafana/data'; -import { ConfigSection, DataSourceDescription } from '@grafana/experimental'; +import { ConfigSection, DataSourceDescription } from '@grafana/plugin-ui'; import { getBackendSrv, getTemplateSrv, isFetchError, TemplateSrv, config } from '@grafana/runtime'; import { Alert, Divider, SecureSocksProxySettings } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/CurrentUserFallbackCredentials.tsx b/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/CurrentUserFallbackCredentials.tsx index 951afa88344..d13296dc1c5 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/CurrentUserFallbackCredentials.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/CurrentUserFallbackCredentials.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { AadCurrentUserCredentials, AzureCredentials, instanceOfAzureCredential } from '@grafana/azure-sdk'; import { SelectableValue } from '@grafana/data'; -import { ConfigSection } from '@grafana/experimental'; +import { ConfigSection } from '@grafana/plugin-ui'; import { config } from '@grafana/runtime'; import { Select, Field, RadioButtonGroup, Alert, Stack } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/AdvancedResourcePicker.tsx b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/AdvancedResourcePicker.tsx index 26efe10eb20..640fdd1a6c7 100644 --- a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/AdvancedResourcePicker.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/AdvancedResourcePicker.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { AccessoryButton } from '@grafana/experimental'; +import { AccessoryButton } from '@grafana/plugin-ui'; import { Icon, Input, Tooltip, Label, Button, useStyles2 } from '@grafana/ui'; export interface ResourcePickerProps { diff --git a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.tsx index b1b13313f7c..622d6b660c8 100644 --- a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { PanelData, TimeRange } from '@grafana/data'; -import { EditorFieldGroup, EditorRow, EditorRows } from '@grafana/experimental'; +import { EditorFieldGroup, EditorRow, EditorRows } from '@grafana/plugin-ui'; import { getTemplateSrv } from '@grafana/runtime'; import { Alert, LinkButton, Text, TextLink } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/AdvancedResourcePicker.tsx b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/AdvancedResourcePicker.tsx index b052df9448c..7ab12383dc5 100644 --- a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/AdvancedResourcePicker.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/AdvancedResourcePicker.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { AccessoryButton } from '@grafana/experimental'; +import { AccessoryButton } from '@grafana/plugin-ui'; import { Input, Label, InlineField, Button, useStyles2 } from '@grafana/ui'; import { selectors } from '../../e2e/selectors'; diff --git a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/DimensionFields.tsx b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/DimensionFields.tsx index b7b7c2f13d6..9a4e1975a74 100644 --- a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/DimensionFields.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/DimensionFields.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { SelectableValue, DataFrame, PanelData, Labels } from '@grafana/data'; -import { EditorList, AccessoryButton } from '@grafana/experimental'; +import { EditorList, AccessoryButton } from '@grafana/plugin-ui'; import { Select, HorizontalGroup, MultiSelect } from '@grafana/ui'; import { AzureMetricDimension, AzureMonitorOption, AzureMonitorQuery, AzureQueryEditorFieldProps } from '../../types'; diff --git a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.tsx index 751392acf44..6048ebca21e 100644 --- a/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/MetricsQueryEditor/MetricsQueryEditor.tsx @@ -1,5 +1,5 @@ import { PanelData } from '@grafana/data'; -import { EditorRows, EditorRow, EditorFieldGroup } from '@grafana/experimental'; +import { EditorRows, EditorRow, EditorFieldGroup } from '@grafana/plugin-ui'; import { multiResourceCompatibleTypes } from '../../azureMetadata'; import type Datasource from '../../datasource'; diff --git a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx index 737053e81db..7fddd79ce0c 100644 --- a/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { SelectableValue } from '@grafana/data'; -import { EditorHeader, InlineSelect } from '@grafana/experimental'; +import { EditorHeader, InlineSelect } from '@grafana/plugin-ui'; import { selectors } from '../../e2e/selectors'; import { AzureMonitorQuery, AzureQueryType } from '../../types'; diff --git a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filter.tsx b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filter.tsx index 12f985dbb92..15d4f7793ee 100644 --- a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filter.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filter.tsx @@ -5,7 +5,7 @@ import { lastValueFrom } from 'rxjs'; import { CoreApp, DataFrame, getDefaultTimeRange, SelectableValue, TimeRange } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { AccessoryButton } from '@grafana/experimental'; +import { AccessoryButton } from '@grafana/plugin-ui'; import { HorizontalGroup, Select, diff --git a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.tsx b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.tsx index 9c8c84d1bc6..8628766f6ba 100644 --- a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.tsx @@ -2,7 +2,7 @@ import { uniq } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; import { SelectableValue } from '@grafana/data'; -import { EditorList } from '@grafana/experimental'; +import { EditorList } from '@grafana/plugin-ui'; import { Field } from '@grafana/ui'; import { AzureQueryEditorFieldProps, AzureTracesFilter } from '../../types'; diff --git a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/TracesQueryEditor.tsx b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/TracesQueryEditor.tsx index 7ed37f8fa21..b550ecf09a8 100644 --- a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/TracesQueryEditor.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/TracesQueryEditor.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { usePrevious } from 'react-use'; import { TimeRange } from '@grafana/data'; -import { EditorFieldGroup, EditorRow, EditorRows } from '@grafana/experimental'; +import { EditorFieldGroup, EditorRow, EditorRows } from '@grafana/plugin-ui'; import { Input } from '@grafana/ui'; import Datasource from '../../datasource'; diff --git a/public/app/plugins/datasource/azuremonitor/components/shared/Field.tsx b/public/app/plugins/datasource/azuremonitor/components/shared/Field.tsx index 2657c066115..3c5a9cf1123 100644 --- a/public/app/plugins/datasource/azuremonitor/components/shared/Field.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/shared/Field.tsx @@ -1,4 +1,4 @@ -import { EditorField } from '@grafana/experimental'; +import { EditorField } from '@grafana/plugin-ui'; import { InlineField } from '@grafana/ui'; import { Props as InlineFieldProps } from '@grafana/ui/src/components/Forms/InlineField'; diff --git a/public/app/plugins/datasource/azuremonitor/package.json b/public/app/plugins/datasource/azuremonitor/package.json index 1a6ea15bb03..0f8b460420e 100644 --- a/public/app/plugins/datasource/azuremonitor/package.json +++ b/public/app/plugins/datasource/azuremonitor/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", - "@grafana/experimental": "2.1.6", + "@grafana/plugin-ui": "0.9.6", "@grafana/runtime": "11.5.0-pre", "@grafana/schema": "11.5.0-pre", "@grafana/ui": "11.5.0-pre", @@ -32,8 +32,8 @@ "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.14", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/prismjs": "1.26.5", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.tsx index 8d9c874bb38..2d5089f06fc 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/Aggregation.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; -import { EditorField } from '@grafana/experimental'; +import { EditorField } from '@grafana/plugin-ui'; import { Select } from '@grafana/ui'; import { getAggregationOptionsByMetric } from '../functions'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/AliasBy.tsx b/public/app/plugins/datasource/cloud-monitoring/components/AliasBy.tsx index a275b89e15f..6b92a061e66 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/AliasBy.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/AliasBy.tsx @@ -2,7 +2,7 @@ import { debounce } from 'lodash'; import { useState } from 'react'; import * as React from 'react'; -import { EditorField } from '@grafana/experimental'; +import { EditorField } from '@grafana/plugin-ui'; import { Input } from '@grafana/ui'; export interface Props { diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Alignment.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Alignment.tsx index c9a313e48ed..1a3d21c3a9d 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/Alignment.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/Alignment.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; -import { EditorField, EditorFieldGroup } from '@grafana/experimental'; +import { EditorField, EditorFieldGroup } from '@grafana/plugin-ui'; import { ALIGNMENT_PERIODS } from '../constants'; import CloudMonitoringDatasource from '../datasource'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx index 35f524cc556..6925e724d68 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { useDebounce } from 'react-use'; import { QueryEditorProps, getDefaultTimeRange, toOption } from '@grafana/data'; -import { EditorField, EditorRows } from '@grafana/experimental'; +import { EditorField, EditorRows } from '@grafana/plugin-ui'; import { Input } from '@grafana/ui'; import CloudMonitoringDatasource from '../datasource'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx index 02943e876ef..9889693efae 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx @@ -1,8 +1,8 @@ import { PureComponent } from 'react'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; -import { ConfigSection, DataSourceDescription } from '@grafana/experimental'; import { ConnectionConfig } from '@grafana/google-sdk'; +import { ConfigSection, DataSourceDescription } from '@grafana/plugin-ui'; import { reportInteraction, config } from '@grafana/runtime'; import { Divider, SecureSocksProxySettings } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/GraphPeriod.tsx b/public/app/plugins/datasource/cloud-monitoring/components/GraphPeriod.tsx index 13dd8e3d201..9e6c7aed0c3 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/GraphPeriod.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/GraphPeriod.tsx @@ -1,5 +1,5 @@ import { SelectableValue } from '@grafana/data'; -import { EditorField, EditorRow } from '@grafana/experimental'; +import { EditorField, EditorRow } from '@grafana/plugin-ui'; import { HorizontalGroup, Switch } from '@grafana/ui'; import { GRAPH_PERIODS } from '../constants'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/GroupBy.tsx b/public/app/plugins/datasource/cloud-monitoring/components/GroupBy.tsx index d9fd0e4c70c..356a81d5ade 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/GroupBy.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/GroupBy.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; -import { EditorField, EditorFieldGroup } from '@grafana/experimental'; +import { EditorField, EditorFieldGroup } from '@grafana/plugin-ui'; import { MultiSelect } from '@grafana/ui'; import { SYSTEM_LABELS } from '../constants'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/LabelFilter.tsx b/public/app/plugins/datasource/cloud-monitoring/components/LabelFilter.tsx index 102bc22ef2f..dca57374c91 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/LabelFilter.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/LabelFilter.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { SelectableValue, toOption } from '@grafana/data'; -import { AccessoryButton, EditorField, EditorList, EditorRow } from '@grafana/experimental'; +import { AccessoryButton, EditorField, EditorList, EditorRow } from '@grafana/plugin-ui'; import { HorizontalGroup, Select } from '@grafana/ui'; import { labelsToGroupedOptions, stringArrayToFilters } from '../functions'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/LookbackPeriodSelect.tsx b/public/app/plugins/datasource/cloud-monitoring/components/LookbackPeriodSelect.tsx index 0e7d991327a..92cce156c87 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/LookbackPeriodSelect.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/LookbackPeriodSelect.tsx @@ -1,5 +1,5 @@ import { SelectableValue } from '@grafana/data'; -import { EditorField } from '@grafana/experimental'; +import { EditorField } from '@grafana/plugin-ui'; import { Select } from '@grafana/ui'; import { LOOKBACK_PERIODS } from '../constants'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/MetricQueryEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/MetricQueryEditor.tsx index 8a245c46503..7cd44f61ffb 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/MetricQueryEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/MetricQueryEditor.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect } from 'react'; import * as React from 'react'; import { SelectableValue, TimeRange } from '@grafana/data'; -import { EditorRows, Stack } from '@grafana/experimental'; +import { EditorRows } from '@grafana/plugin-ui'; +import { Stack } from '@grafana/ui'; import CloudMonitoringDatasource from '../datasource'; import { AlignmentTypes, CloudMonitoringQuery, QueryType, TimeSeriesList, TimeSeriesQuery } from '../types/query'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Preprocessor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Preprocessor.tsx index c7f6c66d355..3ca7d101942 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/Preprocessor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/Preprocessor.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; -import { EditorField } from '@grafana/experimental'; +import { EditorField } from '@grafana/plugin-ui'; import { RadioButtonGroup } from '@grafana/ui'; import { getAlignmentPickerData } from '../functions'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Project.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Project.tsx index de42fbdd788..289b4745ba0 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/Project.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/Project.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { SelectableValue } from '@grafana/data'; -import { EditorField } from '@grafana/experimental'; +import { EditorField } from '@grafana/plugin-ui'; import { Select } from '@grafana/ui'; import CloudMonitoringDatasource from '../datasource'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/PromQLEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/PromQLEditor.tsx index 8e54fbe372c..deaa5e7eaf8 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/PromQLEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/PromQLEditor.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { SelectableValue } from '@grafana/data'; -import { EditorField, EditorRow } from '@grafana/experimental'; +import { EditorField, EditorRow } from '@grafana/plugin-ui'; import { TextArea, Input } from '@grafana/ui'; import CloudMonitoringDatasource from '../datasource'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/QueryEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/QueryEditor.tsx index 9464e273ea2..7b701df34da 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/QueryEditor.tsx @@ -3,7 +3,7 @@ import { isEqual } from 'lodash'; import { useEffect, useState } from 'react'; import { QueryEditorProps, getDefaultTimeRange, toOption } from '@grafana/data'; -import { EditorRows } from '@grafana/experimental'; +import { EditorRows } from '@grafana/plugin-ui'; import { ConfirmModal } from '@grafana/ui'; import CloudMonitoringDatasource from '../datasource'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/QueryHeader.tsx b/public/app/plugins/datasource/cloud-monitoring/components/QueryHeader.tsx index 150bb3c1f1f..71431f0fc20 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/QueryHeader.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/QueryHeader.tsx @@ -1,4 +1,4 @@ -import { EditorHeader, FlexItem, InlineSelect } from '@grafana/experimental'; +import { EditorHeader, FlexItem, InlineSelect } from '@grafana/plugin-ui'; import { QUERY_TYPES } from '../constants'; import { CloudMonitoringQuery } from '../types/query'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/SLO.tsx b/public/app/plugins/datasource/cloud-monitoring/components/SLO.tsx index 2e0117cfe70..be4606155d7 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/SLO.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/SLO.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { SelectableValue } from '@grafana/data'; -import { EditorField } from '@grafana/experimental'; +import { EditorField } from '@grafana/plugin-ui'; import { Select } from '@grafana/ui'; import CloudMonitoringDatasource from '../datasource'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/SLOQueryEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/SLOQueryEditor.tsx index 0d62b1cff1e..1a4ec805596 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/SLOQueryEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/SLOQueryEditor.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import * as React from 'react'; import { SelectableValue } from '@grafana/data'; -import { EditorField, EditorFieldGroup, EditorRow } from '@grafana/experimental'; +import { EditorField, EditorFieldGroup, EditorRow } from '@grafana/plugin-ui'; import { ALIGNMENT_PERIODS, SLO_BURN_RATE_SELECTOR_NAME } from '../constants'; import CloudMonitoringDatasource from '../datasource'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Selector.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Selector.tsx index 690e03219b6..ea0e5903af5 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/Selector.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/Selector.tsx @@ -1,5 +1,5 @@ import { SelectableValue } from '@grafana/data'; -import { EditorField } from '@grafana/experimental'; +import { EditorField } from '@grafana/plugin-ui'; import { Select } from '@grafana/ui'; import { SELECTORS } from '../constants'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Service.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Service.tsx index 35093b92a7b..9425437d7b3 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/Service.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/Service.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { SelectableValue } from '@grafana/data'; -import { EditorField } from '@grafana/experimental'; +import { EditorField } from '@grafana/plugin-ui'; import { Select } from '@grafana/ui'; import CloudMonitoringDatasource from '../datasource'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/VisualMetricQueryEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/VisualMetricQueryEditor.tsx index d50b6011e8e..56824f89358 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/VisualMetricQueryEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/VisualMetricQueryEditor.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect, useState } from 'react'; import * as React from 'react'; import { GrafanaTheme2, SelectableValue, TimeRange } from '@grafana/data'; -import { EditorField, EditorFieldGroup, EditorRow } from '@grafana/experimental'; +import { EditorField, EditorFieldGroup, EditorRow } from '@grafana/plugin-ui'; import { reportInteraction } from '@grafana/runtime'; import { getSelectStyles, Select, AsyncSelect, useStyles2, useTheme2 } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/cloud-monitoring/package.json b/public/app/plugins/datasource/cloud-monitoring/package.json index c278483c99f..b281d43e7a3 100644 --- a/public/app/plugins/datasource/cloud-monitoring/package.json +++ b/public/app/plugins/datasource/cloud-monitoring/package.json @@ -6,8 +6,8 @@ "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", - "@grafana/experimental": "2.1.6", "@grafana/google-sdk": "0.1.2", + "@grafana/plugin-ui": "0.9.6", "@grafana/runtime": "11.5.0-pre", "@grafana/schema": "11.5.0-pre", "@grafana/ui": "11.5.0-pre", @@ -34,8 +34,8 @@ "@testing-library/user-event": "14.5.2", "@types/debounce-promise": "3.1.9", "@types/jest": "29.5.14", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/prismjs": "1.26.5", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json index 5ebdb88eb3e..2f85083d376 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json @@ -22,8 +22,8 @@ "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.14", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/react": "18.3.18", "ts-node": "10.9.2", "typescript": "5.7.3", diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json index 75c7b27c73d..ca1682f1758 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json @@ -26,8 +26,8 @@ "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.14", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/prismjs": "1.26.5", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/package.json b/public/app/plugins/datasource/grafana-testdata-datasource/package.json index 3726f299ff1..47acd088aeb 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/package.json +++ b/public/app/plugins/datasource/grafana-testdata-datasource/package.json @@ -29,8 +29,8 @@ "@testing-library/user-event": "14.5.2", "@types/d3-random": "^3.0.2", "@types/jest": "29.5.14", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "@types/uuid": "10.0.0", diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FromSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FromSection.tsx index 458f2eb9176..153ec2aa039 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FromSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FromSection.tsx @@ -1,4 +1,4 @@ -import { AccessoryButton } from '@grafana/experimental'; +import { AccessoryButton } from '@grafana/plugin-ui'; import { DEFAULT_POLICY } from '../../../../../types'; import { toSelectableValue } from '../utils/toSelectableValue'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx index 3bb380ee126..14f188bd683 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; import { Fragment, useMemo } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { AccessoryButton } from '@grafana/experimental'; +import { AccessoryButton } from '@grafana/plugin-ui'; import { useTheme2 } from '@grafana/ui'; import { toSelectableValue } from '../utils/toSelectableValue'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx index 9079bc056c7..3edc8320144 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx @@ -1,5 +1,5 @@ import { SelectableValue } from '@grafana/data'; -import { AccessoryButton } from '@grafana/experimental'; +import { AccessoryButton } from '@grafana/plugin-ui'; import { InfluxQueryTag } from '../../../../../types'; import { adjustOperatorIfNeeded, getCondition, getOperator } from '../utils/tagUtils'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tags.test.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tags.test.tsx index 7d6b19d637f..910bb67fca5 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tags.test.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tags.test.tsx @@ -38,13 +38,12 @@ jest.mock('../../../../../influxql_metadata_query', () => { }; }); -jest.mock('@grafana/runtime', () => { - return { - getTemplateSrv: jest.fn().mockReturnValueOnce({ - getVariables: jest.fn().mockReturnValueOnce([]), - }), - }; -}); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getTemplateSrv: jest.fn().mockReturnValueOnce({ + getVariables: jest.fn().mockReturnValueOnce([]), + }), +})); beforeEach(() => { (mockedMeta.getTagKeys as jest.Mock).mockClear(); diff --git a/public/app/plugins/datasource/influxdb/fsql/datasource.flightsql.ts b/public/app/plugins/datasource/influxdb/fsql/datasource.flightsql.ts index ce40ff3c53a..da0a3ed5f25 100644 --- a/public/app/plugins/datasource/influxdb/fsql/datasource.flightsql.ts +++ b/public/app/plugins/datasource/influxdb/fsql/datasource.flightsql.ts @@ -1,7 +1,7 @@ import { v4 as uuidv4 } from 'uuid'; import { DataSourceInstanceSettings, TimeRange } from '@grafana/data'; -import { CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/experimental'; +import { CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/plugin-ui'; import { TemplateSrv, config, getTemplateSrv } from '@grafana/runtime'; import { COMMON_FNS, DB, FuncParameter, SQLQuery, SqlDatasource, formatSQL } from '@grafana/sql'; diff --git a/public/app/plugins/datasource/influxdb/fsql/sqlCompletionProvider.ts b/public/app/plugins/datasource/influxdb/fsql/sqlCompletionProvider.ts index b44b2475027..19fca7d6f56 100644 --- a/public/app/plugins/datasource/influxdb/fsql/sqlCompletionProvider.ts +++ b/public/app/plugins/datasource/influxdb/fsql/sqlCompletionProvider.ts @@ -11,7 +11,7 @@ import { TableDefinition, TableIdentifier, TokenType, -} from '@grafana/experimental'; +} from '@grafana/plugin-ui'; interface CompletionProviderGetterArgs { getMeta: (t?: TableIdentifier) => Promise; diff --git a/public/app/plugins/datasource/jaeger/package.json b/public/app/plugins/datasource/jaeger/package.json index a0aefef0d6f..a882e2093de 100644 --- a/public/app/plugins/datasource/jaeger/package.json +++ b/public/app/plugins/datasource/jaeger/package.json @@ -29,9 +29,9 @@ "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.14", - "@types/lodash": "4.17.14", + "@types/lodash": "4.17.15", "@types/logfmt": "^1.2.3", - "@types/node": "22.10.10", + "@types/node": "22.12.0", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "@types/react-window": "1.8.8", diff --git a/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx b/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx index 607dc32f114..287257b4eaf 100644 --- a/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx +++ b/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx @@ -10,7 +10,7 @@ import { updateDatasourcePluginJsonDataOption, updateDatasourcePluginResetOption, } from '@grafana/data'; -import { ConfigSection, ConfigSubSection, DataSourceDescription } from '@grafana/experimental'; +import { ConfigSection, ConfigSubSection, DataSourceDescription } from '@grafana/plugin-ui'; import { config } from '@grafana/runtime'; import { ConnectionLimits, useMigrateDatabaseFields } from '@grafana/sql'; import { NumberInput } from '@grafana/sql/src/components/configuration/NumberInput'; diff --git a/public/app/plugins/datasource/mssql/configuration/Kerberos.tsx b/public/app/plugins/datasource/mssql/configuration/Kerberos.tsx index f5dfc8759c3..d1166ba79e0 100644 --- a/public/app/plugins/datasource/mssql/configuration/Kerberos.tsx +++ b/public/app/plugins/datasource/mssql/configuration/Kerberos.tsx @@ -1,7 +1,7 @@ import { SyntheticEvent } from 'react'; import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption } from '@grafana/data'; -import { ConfigSubSection } from '@grafana/experimental'; +import { ConfigSubSection } from '@grafana/plugin-ui'; import { FieldSet, Input, Field } from '@grafana/ui'; import { MSSQLAuthenticationType, MssqlOptions } from '../types'; diff --git a/public/app/plugins/datasource/mssql/datasource.ts b/public/app/plugins/datasource/mssql/datasource.ts index abe0ee75b13..b020d051975 100644 --- a/public/app/plugins/datasource/mssql/datasource.ts +++ b/public/app/plugins/datasource/mssql/datasource.ts @@ -1,7 +1,7 @@ import { v4 as uuidv4 } from 'uuid'; import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data'; -import { LanguageDefinition } from '@grafana/experimental'; +import { LanguageDefinition } from '@grafana/plugin-ui'; import { TemplateSrv, config } from '@grafana/runtime'; import { COMMON_FNS, diff --git a/public/app/plugins/datasource/mssql/package.json b/public/app/plugins/datasource/mssql/package.json index af7fab99453..008012c3d27 100644 --- a/public/app/plugins/datasource/mssql/package.json +++ b/public/app/plugins/datasource/mssql/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", - "@grafana/experimental": "2.1.6", + "@grafana/plugin-ui": "0.9.6", "@grafana/runtime": "11.5.0-pre", "@grafana/sql": "11.5.0-pre", "@grafana/ui": "11.5.0-pre", @@ -22,8 +22,8 @@ "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.14", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/react": "18.3.18", "ts-node": "10.9.2", "typescript": "5.7.3", diff --git a/public/app/plugins/datasource/mssql/sqlCompletionProvider.ts b/public/app/plugins/datasource/mssql/sqlCompletionProvider.ts index 871c2172dfc..72cc15f421d 100644 --- a/public/app/plugins/datasource/mssql/sqlCompletionProvider.ts +++ b/public/app/plugins/datasource/mssql/sqlCompletionProvider.ts @@ -6,7 +6,7 @@ import { TableDefinition, TableIdentifier, TokenType, -} from '@grafana/experimental'; +} from '@grafana/plugin-ui'; import { DB, SQLQuery } from '@grafana/sql'; interface CompletionProviderGetterArgs { diff --git a/public/app/plugins/datasource/mysql/package.json b/public/app/plugins/datasource/mysql/package.json index b35ff254ad9..2cbed20190a 100644 --- a/public/app/plugins/datasource/mysql/package.json +++ b/public/app/plugins/datasource/mysql/package.json @@ -22,8 +22,8 @@ "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.14", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/react": "18.3.18", "ts-node": "10.9.2", "typescript": "5.7.3", diff --git a/public/app/plugins/datasource/parca/package.json b/public/app/plugins/datasource/parca/package.json index df78383ef64..cf4dcec1268 100644 --- a/public/app/plugins/datasource/parca/package.json +++ b/public/app/plugins/datasource/parca/package.json @@ -22,8 +22,8 @@ "@testing-library/dom": "10.4.0", "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "ts-node": "10.9.2", diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json index b42d506a96c..55a87c3c5f3 100644 --- a/public/app/plugins/datasource/tempo/package.json +++ b/public/app/plugins/datasource/tempo/package.json @@ -45,8 +45,8 @@ "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.14", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/prismjs": "1.26.5", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", diff --git a/public/app/plugins/datasource/tempo/traceql/TempoQueryBuilderOptions.tsx b/public/app/plugins/datasource/tempo/traceql/TempoQueryBuilderOptions.tsx index 25a13cc349a..f273bc788ca 100644 --- a/public/app/plugins/datasource/tempo/traceql/TempoQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/tempo/traceql/TempoQueryBuilderOptions.tsx @@ -1,7 +1,9 @@ +import { css } from '@emotion/css'; import * as React from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; import { EditorField, EditorRow } from '@grafana/experimental'; -import { AutoSizeInput, RadioButtonGroup } from '@grafana/ui'; +import { AutoSizeInput, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { QueryOptionGroup } from '../_importedDependencies/datasources/prometheus/QueryOptionGroup'; import { SearchTableType } from '../dataquery.gen'; @@ -28,6 +30,8 @@ const parseIntWithFallback = (val: string, fallback: number) => { }; export const TempoQueryBuilderOptions = React.memo(({ onChange, query, isStreaming }) => { + const styles = useStyles2(getStyles); + if (!query.hasOwnProperty('limit')) { query.limit = DEFAULT_LIMIT; } @@ -60,21 +64,23 @@ export const TempoQueryBuilderOptions = React.memo(({ onChange, query, is // } // }; - const collapsedInfoList = [ + const collapsedSearchOptions = [ `Limit: ${query.limit || DEFAULT_LIMIT}`, `Spans Limit: ${query.spss || DEFAULT_SPSS}`, `Table Format: ${query.tableType === SearchTableType.Traces ? 'Traces' : 'Spans'}`, '|', - `Step: ${query.step || 'auto'}`, - // `Exemplars: ${query.exemplars !== undefined ? query.exemplars : 'auto'}`, - '|', `Streaming: ${isStreaming ? 'Enabled' : 'Disabled'}`, ]; + const collapsedMetricsOptions = [ + `Step: ${query.step || 'auto'}`, + // `Exemplars: ${query.exemplars !== undefined ? query.exemplars : 'auto'}`, + ]; + return ( - <> - - + +
+ (({ onChange, query, is onChange={onTableTypeChange} /> + } tooltipInteractive> +
{isStreaming ? 'Enabled' : 'Disabled'}
+
+
+ + (({ onChange, query, is {/* value={query.exemplars}*/} {/* />*/} {/**/} - } tooltipInteractive> -
{isStreaming ? 'Enabled' : 'Disabled'}
-
- - +
+
); }); @@ -163,3 +172,17 @@ const StreamingTooltip = () => { }; TempoQueryBuilderOptions.displayName = 'TempoQueryBuilderOptions'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + options: css({ + display: 'flex', + width: '-webkit-fill-available', + gap: theme.spacing(1), + + '> div': { + width: 'auto', + }, + }), + }; +}; diff --git a/public/app/plugins/datasource/zipkin/datasource.test.ts b/public/app/plugins/datasource/zipkin/datasource.test.ts index 4123de7960d..5b1ab000004 100644 --- a/public/app/plugins/datasource/zipkin/datasource.test.ts +++ b/public/app/plugins/datasource/zipkin/datasource.test.ts @@ -1,5 +1,4 @@ import { lastValueFrom, of } from 'rxjs'; -import { createFetchResponse } from 'test/helpers/createFetchResponse'; import { DataFrameView, @@ -8,52 +7,49 @@ import { DataSourcePluginMeta, FieldType, } from '@grafana/data'; -import { BackendSrv, TemplateSrv } from '@grafana/runtime'; +import { BackendSrv, getBackendSrv, setBackendSrv, TemplateSrv } from '@grafana/runtime'; import { addNodeGraphFramesToResponse, ZipkinDatasource } from './datasource'; import mockJson from './mocks/mockJsonResponse.json'; import { mockTraceDataFrame } from './mocks/mockTraceDataFrame'; -import { ZipkinQuery, ZipkinSpan } from './types'; -import { traceFrameFields, zipkinResponse } from './utils/testData'; +import { ZipkinQuery } from './types'; -export const backendSrv = { fetch: jest.fn() } as unknown as BackendSrv; - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getBackendSrv: () => backendSrv, -})); +const templateSrv: TemplateSrv = { + replace: jest.fn().mockImplementation((value) => value), + getVariables: jest.fn(), + containsTemplate: jest.fn(), + updateTimeRange: jest.fn(), +}; describe('ZipkinDatasource', () => { + let origBackendSrv: BackendSrv; + let ds: ZipkinDatasource; + beforeEach(() => { + origBackendSrv = getBackendSrv(); + setBackendSrv({ ...origBackendSrv, fetch: jest.fn().mockReturnValue(of({ data: {} })) }); + ds = new ZipkinDatasource(defaultSettings, templateSrv); + }); + + afterEach(() => { + setBackendSrv(origBackendSrv); + jest.clearAllMocks(); + }); describe('query', () => { - const templateSrv: TemplateSrv = { - replace: jest.fn(), - getVariables: jest.fn(), - containsTemplate: jest.fn(), - updateTimeRange: jest.fn(), - }; - it('runs query', async () => { - setupBackendSrv(zipkinResponse); - const ds = new ZipkinDatasource(defaultSettings, templateSrv); - await expect(ds.query({ targets: [{ query: '12345' }] } as DataQueryRequest)).toEmitValuesWith( - (val) => { - expect(val[0].data[0].fields).toMatchObject(traceFrameFields); - } - ); - }); + const origBackendSrv = getBackendSrv(); + setBackendSrv({ + ...origBackendSrv, + fetch: jest.fn().mockReturnValue(of({ data: [] })), + }); - it('runs query with traceId that includes special characters', async () => { - setupBackendSrv(zipkinResponse); - const ds = new ZipkinDatasource(defaultSettings, templateSrv); - await expect(ds.query({ targets: [{ query: 'a/b' }] } as DataQueryRequest)).toEmitValuesWith( - (val) => { - expect(val[0].data[0].fields).toMatchObject(traceFrameFields); - } - ); + const response = await lastValueFrom(ds.query({ targets: [{ query: 'test' }] } as DataQueryRequest)); + expect(response).toEqual({ + state: 'Done', + data: [], + }); }); it('should handle json file upload', async () => { - const ds = new ZipkinDatasource(defaultSettings); ds.uploadedJson = JSON.stringify(mockJson); const response = await lastValueFrom( ds.query({ @@ -67,7 +63,6 @@ describe('ZipkinDatasource', () => { }); it('should fail on invalid json file upload', async () => { - const ds = new ZipkinDatasource(defaultSettings); ds.uploadedJson = JSON.stringify({ key: 'value', arr: [] }); const response = await lastValueFrom( ds.query({ @@ -81,7 +76,10 @@ describe('ZipkinDatasource', () => { describe('metadataRequest', () => { it('runs query', async () => { - setupBackendSrv(['service 1', 'service 2'] as unknown as ZipkinSpan[]); + setBackendSrv({ + ...origBackendSrv, + fetch: jest.fn().mockReturnValue(of({ data: ['service 1', 'service 2'] })), + }); const ds = new ZipkinDatasource(defaultSettings); const response = await ds.metadataRequest('services'); expect(response).toEqual(['service 1', 'service 2']); @@ -118,13 +116,6 @@ describe('addNodeGraphFramesToResponse', () => { }); }); -function setupBackendSrv(response: ZipkinSpan[]) { - const defaultMock = () => of(createFetchResponse(response)); - - const fetchMock = jest.spyOn(backendSrv, 'fetch'); - fetchMock.mockImplementation(defaultMock); -} - const defaultSettings: DataSourceInstanceSettings = { id: 1, uid: '1', diff --git a/public/app/plugins/datasource/zipkin/datasource.ts b/public/app/plugins/datasource/zipkin/datasource.ts index 54ff15dafb1..7b49e7a0c1c 100644 --- a/public/app/plugins/datasource/zipkin/datasource.ts +++ b/public/app/plugins/datasource/zipkin/datasource.ts @@ -1,4 +1,4 @@ -import { lastValueFrom, Observable, of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; import { @@ -9,26 +9,15 @@ import { FieldType, createDataFrame, ScopedVars, - urlUtil, toDataFrame, } from '@grafana/data'; import { createNodeGraphFrames, NodeGraphOptions, SpanBarOptions } from '@grafana/o11y-ds-frontend'; -import { - BackendSrvRequest, - config, - DataSourceWithBackend, - FetchResponse, - getBackendSrv, - getTemplateSrv, - TemplateSrv, -} from '@grafana/runtime'; +import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; import { ZipkinQuery, ZipkinSpan } from './types'; import { createGraphFrames } from './utils/graphTransform'; import { transformResponse } from './utils/transforms'; -const apiPrefix = '/api/v2'; - export interface ZipkinJsonData extends DataSourceJsonData { nodeGraph?: NodeGraphOptions; } @@ -38,7 +27,7 @@ export class ZipkinDatasource extends DataSourceWithBackend, + instanceSettings: DataSourceInstanceSettings, private readonly templateSrv: TemplateSrv = getTemplateSrv() ) { super(instanceSettings); @@ -61,40 +50,24 @@ export class ZipkinDatasource extends DataSourceWithBackend { - if (this.nodeGraph?.enabled) { - return addNodeGraphFramesToResponse(response); - } - return response; - }) - ); - } - const query = this.applyTemplateVariables(target, options.scopedVars); - return this.request(`${apiPrefix}/trace/${encodeURIComponent(query.query)}`).pipe( - map((res) => responseToDataQueryResponse(res, this.nodeGraph?.enabled)) + return super.query(options).pipe( + map((response) => { + if (this.nodeGraph?.enabled) { + return addNodeGraphFramesToResponse(response); + } + return response; + }) ); } return of(emptyDataQueryResponse); } async metadataRequest(url: string, params?: Record) { - if (config.featureToggles.zipkinBackendMigration) { - return await this.getResource(url, params); - } - const urlWithPrefix = `${apiPrefix}/${url}`; - const res = await lastValueFrom(this.request(urlWithPrefix, params, { hideFromInspector: true })); - return res.data; + return await this.getResource(url, params); } async testDatasource(): Promise<{ status: string; message: string }> { - if (config.featureToggles.zipkinBackendMigration) { - return await super.testDatasource(); - } - - await this.metadataRequest('services'); - return { status: 'success', message: 'Data source is working' }; + return await super.testDatasource(); } getQueryDisplayText(query: ZipkinQuery): string { @@ -123,21 +96,6 @@ export class ZipkinDatasource extends DataSourceWithBackend( - apiUrl: string, - data?: unknown, - options?: Partial - ): Observable> { - const params = data ? urlUtil.serializeParams(data) : ''; - const url = `${this.instanceSettings.url}${apiUrl}${params.length ? `?${params}` : ''}`; - const req = { - ...options, - url, - }; - - return getBackendSrv().fetch(req); - } } function responseToDataQueryResponse(response: { data: ZipkinSpan[] }, nodeGraph = false): DataQueryResponse { diff --git a/public/app/plugins/datasource/zipkin/package.json b/public/app/plugins/datasource/zipkin/package.json index b52961af94a..efddda4e217 100644 --- a/public/app/plugins/datasource/zipkin/package.json +++ b/public/app/plugins/datasource/zipkin/package.json @@ -25,8 +25,8 @@ "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.1.0", "@types/jest": "29.5.14", - "@types/lodash": "4.17.14", - "@types/node": "22.10.10", + "@types/lodash": "4.17.15", + "@types/node": "22.12.0", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "ts-node": "10.9.2", diff --git a/public/test/helpers/alertingRuleEditor.tsx b/public/test/helpers/alertingRuleEditor.tsx index 1f1dc3552f2..4b50dce768e 100644 --- a/public/test/helpers/alertingRuleEditor.tsx +++ b/public/test/helpers/alertingRuleEditor.tsx @@ -4,7 +4,8 @@ import { byRole, byTestId, byText } from 'testing-library-selector'; import { selectors } from '@grafana/e2e-selectors'; import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; -import RuleEditor from 'app/features/alerting/unified/RuleEditor'; +import RuleEditor from 'app/features/alerting/unified/rule-editor/RuleEditor'; + export enum GrafanaRuleFormStep { Query = 2, Notification = 5, diff --git a/yarn.lock b/yarn.lock index 4c42d41e41d..53483ac0007 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2706,8 +2706,8 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.6" "@grafana/plugin-configs": "npm:11.5.0-pre" + "@grafana/plugin-ui": "npm:0.9.6" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/schema": "npm:11.5.0-pre" "@grafana/ui": "npm:11.5.0-pre" @@ -2717,8 +2717,8 @@ __metadata: "@testing-library/react": "npm:16.1.0" "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.14" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/prismjs": "npm:1.26.5" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" @@ -2759,8 +2759,8 @@ __metadata: "@testing-library/react": "npm:16.1.0" "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.14" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/react": "npm:18.3.18" lodash: "npm:4.17.21" react: "npm:18.3.1" @@ -2789,8 +2789,8 @@ __metadata: "@testing-library/react": "npm:16.1.0" "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.14" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/prismjs": "npm:1.26.5" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" @@ -2831,8 +2831,8 @@ __metadata: "@testing-library/user-event": "npm:14.5.2" "@types/d3-random": "npm:^3.0.2" "@types/jest": "npm:29.5.14" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" "@types/uuid": "npm:10.0.0" @@ -2871,9 +2871,9 @@ __metadata: "@testing-library/react": "npm:16.1.0" "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.14" - "@types/lodash": "npm:4.17.14" + "@types/lodash": "npm:4.17.15" "@types/logfmt": "npm:^1.2.3" - "@types/node": "npm:22.10.10" + "@types/node": "npm:22.12.0" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" "@types/react-window": "npm:1.8.8" @@ -2903,8 +2903,8 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.6" "@grafana/plugin-configs": "npm:11.5.0-pre" + "@grafana/plugin-ui": "npm:0.9.6" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/sql": "npm:11.5.0-pre" "@grafana/ui": "npm:11.5.0-pre" @@ -2912,8 +2912,8 @@ __metadata: "@testing-library/react": "npm:16.1.0" "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.14" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/react": "npm:18.3.18" lodash: "npm:4.17.21" react: "npm:18.3.1" @@ -2943,8 +2943,8 @@ __metadata: "@testing-library/react": "npm:16.1.0" "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.14" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/react": "npm:18.3.18" lodash: "npm:4.17.21" react: "npm:18.3.1" @@ -2971,8 +2971,8 @@ __metadata: "@testing-library/dom": "npm:10.4.0" "@testing-library/react": "npm:16.1.0" "@testing-library/user-event": "npm:14.5.2" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" lodash: "npm:4.17.21" @@ -2997,9 +2997,9 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.6" "@grafana/google-sdk": "npm:0.1.2" "@grafana/plugin-configs": "npm:11.5.0-pre" + "@grafana/plugin-ui": "npm:0.9.6" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/schema": "npm:11.5.0-pre" "@grafana/ui": "npm:11.5.0-pre" @@ -3009,8 +3009,8 @@ __metadata: "@testing-library/user-event": "npm:14.5.2" "@types/debounce-promise": "npm:3.1.9" "@types/jest": "npm:29.5.14" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/prismjs": "npm:1.26.5" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" @@ -3062,8 +3062,8 @@ __metadata: "@testing-library/react": "npm:16.1.0" "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.14" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/prismjs": "npm:1.26.5" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" @@ -3112,8 +3112,8 @@ __metadata: "@testing-library/jest-dom": "npm:6.6.3" "@testing-library/react": "npm:16.1.0" "@types/jest": "npm:29.5.14" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" lodash: "npm:4.17.21" @@ -3205,8 +3205,8 @@ __metadata: "@rollup/plugin-node-resolve": "npm:16.0.0" "@types/d3-interpolate": "npm:^3.0.0" "@types/history": "npm:4.7.11" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/papaparse": "npm:5.3.15" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" @@ -3223,9 +3223,9 @@ __metadata: marked: "npm:15.0.6" marked-mangle: "npm:1.1.10" moment: "npm:2.30.1" - moment-timezone: "npm:0.5.46" + moment-timezone: "npm:0.5.47" ol: "npm:7.4.0" - papaparse: "npm:5.5.1" + papaparse: "npm:5.5.2" react: "npm:18.3.1" react-dom: "npm:18.3.1" react-use: "npm:17.6.0" @@ -3264,7 +3264,7 @@ __metadata: dependencies: "@grafana/tsconfig": "npm:^2.0.0" "@rollup/plugin-node-resolve": "npm:16.0.0" - "@types/node": "npm:22.10.10" + "@types/node": "npm:22.12.0" "@types/semver": "npm:7.5.8" esbuild: "npm:0.24.2" rimraf: "npm:6.0.1" @@ -3398,8 +3398,8 @@ __metadata: "@testing-library/user-event": "npm:14.5.2" "@types/d3": "npm:^7" "@types/jest": "npm:^29.5.4" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/react": "npm:18.3.18" "@types/react-virtualized-auto-sizer": "npm:1.0.4" "@types/tinycolor2": "npm:1.4.6" @@ -3497,7 +3497,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.6" + "@grafana/plugin-ui": "npm:0.9.6" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/schema": "npm:11.5.0-pre" "@grafana/tsconfig": "npm:^2.0.0" @@ -3507,7 +3507,7 @@ __metadata: "@testing-library/react": "npm:16.1.0" "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:^29.5.4" - "@types/node": "npm:22.10.10" + "@types/node": "npm:22.12.0" "@types/react": "npm:18.3.18" "@types/systemjs": "npm:6.15.1" jest: "npm:^29.6.4" @@ -3627,8 +3627,8 @@ __metadata: "@types/eslint": "npm:9.6.1" "@types/jest": "npm:29.5.14" "@types/jquery": "npm:3.5.32" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/pluralize": "npm:^0.0.33" "@types/prismjs": "npm:1.26.5" "@types/react": "npm:18.3.18" @@ -3666,7 +3666,7 @@ __metadata: marked: "npm:15.0.6" marked-mangle: "npm:1.1.10" moment: "npm:2.30.1" - moment-timezone: "npm:0.5.46" + moment-timezone: "npm:0.5.47" monaco-promql: "npm:1.7.4" pluralize: "npm:8.0.0" prettier: "npm:3.4.2" @@ -3719,7 +3719,7 @@ __metadata: "@types/angular": "npm:1.8.9" "@types/history": "npm:4.7.11" "@types/jest": "npm:29.5.14" - "@types/lodash": "npm:4.17.14" + "@types/lodash": "npm:4.17.15" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" "@types/systemjs": "npm:6.15.1" @@ -3779,7 +3779,7 @@ __metadata: "@svgr/plugin-prettier": "npm:^8.1.0" "@svgr/plugin-svgo": "npm:^8.1.0" "@types/babel__core": "npm:^7" - "@types/node": "npm:22.10.10" + "@types/node": "npm:22.12.0" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" esbuild: "npm:0.24.2" @@ -3799,11 +3799,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:5.38.0": - version: 5.38.0 - resolution: "@grafana/scenes-react@npm:5.38.0" +"@grafana/scenes-react@npm:5.41.0": + version: 5.41.0 + resolution: "@grafana/scenes-react@npm:5.41.0" dependencies: - "@grafana/scenes": "npm:5.38.0" + "@grafana/scenes": "npm:5.41.0" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3814,13 +3814,13 @@ __metadata: "@grafana/ui": ^11.0.0 react: ^18.0.0 react-dom: ^18.0.0 - checksum: 10/f130dabfd82abeb232f5b9d485102d03d19a7b0a91924899f6df25ee79e093d216738c9c9b12cd0ae0378eeae91dc1a0b06dadc3c9bda80ab7a2f855fb1cdac9 + checksum: 10/80d5b51a190ed962e17468c0646e9b48a52133c3af026f8bc599c5e8e1150bcd0210f7973dd13e28f96a300798a1c2a1ec41c1bcedc580d8da393591d1e69eb4 languageName: node linkType: hard -"@grafana/scenes@npm:5.38.0": - version: 5.38.0 - resolution: "@grafana/scenes@npm:5.38.0" +"@grafana/scenes@npm:5.41.0": + version: 5.41.0 + resolution: "@grafana/scenes@npm:5.41.0" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3837,7 +3837,7 @@ __metadata: "@grafana/ui": ">=10.4" react: ^18.0.0 react-dom: ^18.0.0 - checksum: 10/ed6f67e1d54ec49df77cf5fce55e13402c84755dc2f2ef09650fa44c3783dd7f18d08de26721dd4ba5f51ac02187f2c35d27023d75deb9d71c8599490f05eaf8 + checksum: 10/a77b57688b5ac518fa1f3842a871ad21e2daf840d670abda6b69facce74bb6761195c43f25507a3dff87c8551625a979c4240debe62832834473b67145b5d589 languageName: node linkType: hard @@ -3875,7 +3875,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.6" + "@grafana/plugin-ui": "npm:0.9.6" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@grafana/ui": "npm:11.5.0-pre" @@ -3885,8 +3885,8 @@ __metadata: "@testing-library/react": "npm:16.1.0" "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:^29.5.4" - "@types/lodash": "npm:4.17.14" - "@types/node": "npm:22.10.10" + "@types/lodash": "npm:4.17.15" + "@types/node": "npm:22.12.0" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" "@types/react-virtualized-auto-sizer": "npm:1.0.4" @@ -4048,9 +4048,9 @@ __metadata: "@types/is-hotkey": "npm:0.1.10" "@types/jest": "npm:29.5.14" "@types/jquery": "npm:3.5.32" - "@types/lodash": "npm:4.17.14" + "@types/lodash": "npm:4.17.15" "@types/mock-raf": "npm:1.0.6" - "@types/node": "npm:22.10.10" + "@types/node": "npm:22.12.0" "@types/prismjs": "npm:1.26.5" "@types/react": "npm:18.3.18" "@types/react-color": "npm:3.0.13" @@ -7977,57 +7977,57 @@ __metadata: languageName: node linkType: hard -"@swagger-api/apidom-ast@npm:^1.0.0-beta.5": - version: 1.0.0-beta.5 - resolution: "@swagger-api/apidom-ast@npm:1.0.0-beta.5" +"@swagger-api/apidom-ast@npm:^1.0.0-beta.11, @swagger-api/apidom-ast@npm:^1.0.0-beta.5": + version: 1.0.0-beta.11 + resolution: "@swagger-api/apidom-ast@npm:1.0.0-beta.11" dependencies: "@babel/runtime-corejs3": "npm:^7.20.7" - "@swagger-api/apidom-error": "npm:^1.0.0-beta.5" + "@swagger-api/apidom-error": "npm:^1.0.0-beta.11" "@types/ramda": "npm:~0.30.0" ramda: "npm:~0.30.0" ramda-adjunct: "npm:^5.0.0" unraw: "npm:^3.0.0" - checksum: 10/b2ea32b8ed589a3aff122e9209d5f0c873364bb34b234d13796422d4fce6b9f52fab599ef47956f655316256cf6af821c233117c0ca96a677b867e075b70cb5d + checksum: 10/efe7caf37735f6b1a9b56ca780ce59134540c00c50ef00606607a458905142b7794466cacbffdcd9afe11ece9f5a99651f2f62d24b05f71727d86718e2dea91c languageName: node linkType: hard -"@swagger-api/apidom-core@npm:>=1.0.0-beta.3 <1.0.0-rc.0, @swagger-api/apidom-core@npm:^1.0.0-beta.5": - version: 1.0.0-beta.5 - resolution: "@swagger-api/apidom-core@npm:1.0.0-beta.5" +"@swagger-api/apidom-core@npm:>=1.0.0-beta.11 <1.0.0-rc.0, @swagger-api/apidom-core@npm:^1.0.0-beta.11, @swagger-api/apidom-core@npm:^1.0.0-beta.5": + version: 1.0.0-beta.11 + resolution: "@swagger-api/apidom-core@npm:1.0.0-beta.11" dependencies: "@babel/runtime-corejs3": "npm:^7.20.7" - "@swagger-api/apidom-ast": "npm:^1.0.0-beta.5" - "@swagger-api/apidom-error": "npm:^1.0.0-beta.5" + "@swagger-api/apidom-ast": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-error": "npm:^1.0.0-beta.11" "@types/ramda": "npm:~0.30.0" minim: "npm:~0.23.8" ramda: "npm:~0.30.0" ramda-adjunct: "npm:^5.0.0" short-unique-id: "npm:^5.0.2" ts-mixer: "npm:^6.0.3" - checksum: 10/c034ef286738b2b5aab525b068fd22e1b54145e3024477abcafde926f3783c280c69e4de23cc28d9cc568a62fb02719a0a89e6fd2011136cf447f42ed66fca55 + checksum: 10/bb81c1ef603b80985c8e7ad8808a12eaaf2daa3de2a41ac2f9f8c041c7a7f26bcbfa9713738248db198af1a6b424127b10abd5ac7e6fcad95d852cea8fb8a526 languageName: node linkType: hard -"@swagger-api/apidom-error@npm:>=1.0.0-beta.3 <1.0.0-rc.0, @swagger-api/apidom-error@npm:^1.0.0-beta.3 <1.0.0-rc.0, @swagger-api/apidom-error@npm:^1.0.0-beta.5": - version: 1.0.0-beta.5 - resolution: "@swagger-api/apidom-error@npm:1.0.0-beta.5" +"@swagger-api/apidom-error@npm:>=1.0.0-beta.11 <1.0.0-rc.0, @swagger-api/apidom-error@npm:^1.0.0-beta.11, @swagger-api/apidom-error@npm:^1.0.0-beta.3 <1.0.0-rc.0, @swagger-api/apidom-error@npm:^1.0.0-beta.5": + version: 1.0.0-beta.11 + resolution: "@swagger-api/apidom-error@npm:1.0.0-beta.11" dependencies: "@babel/runtime-corejs3": "npm:^7.20.7" - checksum: 10/defb3ba3775be8a511ff01be4ed7d2eca66faf5ab478f65a39845c8981510a0286e622268240de56613fd3ee37de906a7c6947a82aceb214fdd89d0988b972bb + checksum: 10/d63ec68067a17c10fa51b2929c18d18f397d91ab45961e398ad99341b45e87d60a0ec6f634ac3883733ea7f4a5155e9929207dd44572f65754fa0f5b7d004466 languageName: node linkType: hard -"@swagger-api/apidom-json-pointer@npm:>=1.0.0-beta.3 <1.0.0-rc.0, @swagger-api/apidom-json-pointer@npm:^1.0.0-beta.3 <1.0.0-rc.0, @swagger-api/apidom-json-pointer@npm:^1.0.0-beta.5": - version: 1.0.0-beta.5 - resolution: "@swagger-api/apidom-json-pointer@npm:1.0.0-beta.5" +"@swagger-api/apidom-json-pointer@npm:>=1.0.0-beta.11 <1.0.0-rc.0, @swagger-api/apidom-json-pointer@npm:^1.0.0-beta.11, @swagger-api/apidom-json-pointer@npm:^1.0.0-beta.3 <1.0.0-rc.0": + version: 1.0.0-beta.11 + resolution: "@swagger-api/apidom-json-pointer@npm:1.0.0-beta.11" dependencies: "@babel/runtime-corejs3": "npm:^7.20.7" - "@swagger-api/apidom-core": "npm:^1.0.0-beta.5" - "@swagger-api/apidom-error": "npm:^1.0.0-beta.5" + "@swagger-api/apidom-core": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-error": "npm:^1.0.0-beta.11" "@types/ramda": "npm:~0.30.0" ramda: "npm:~0.30.0" ramda-adjunct: "npm:^5.0.0" - checksum: 10/68b3b196de1d2cb86663350ba70f885599a12c21804cd9bc1be1777ee5e99e9f570623599d476d241bda298e0890c34d2c9a516f7a47cf060992107c31e9d9f5 + checksum: 10/dee858ee4b4a0f93ab082451a703c8b9e0250f7b486d8d9ef243b77f92acc49741cfdd1e10a9cc44d946b64e469aa4016a1380c7a28d660fe048a2897098afc7 languageName: node linkType: hard @@ -8062,50 +8062,82 @@ __metadata: languageName: node linkType: hard -"@swagger-api/apidom-ns-json-schema-draft-4@npm:^1.0.0-beta.5": - version: 1.0.0-beta.5 - resolution: "@swagger-api/apidom-ns-json-schema-draft-4@npm:1.0.0-beta.5" +"@swagger-api/apidom-ns-json-schema-2019-09@npm:^1.0.0-beta.11": + version: 1.0.0-beta.11 + resolution: "@swagger-api/apidom-ns-json-schema-2019-09@npm:1.0.0-beta.11" dependencies: "@babel/runtime-corejs3": "npm:^7.20.7" - "@swagger-api/apidom-ast": "npm:^1.0.0-beta.5" - "@swagger-api/apidom-core": "npm:^1.0.0-beta.5" + "@swagger-api/apidom-core": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-error": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-ns-json-schema-draft-7": "npm:^1.0.0-beta.11" "@types/ramda": "npm:~0.30.0" ramda: "npm:~0.30.0" ramda-adjunct: "npm:^5.0.0" ts-mixer: "npm:^6.0.4" - checksum: 10/2518b3aa9b7387a90553565132101616dc24c6d1f8aee581f3562bac87403f87887217fdb3cf17b113f685e051980fc66ec25d9cab7e4a0bf4922b4bcd3bc502 + checksum: 10/b91f90e7376922f1112752520bcac265c20245c1d2d682cd5ba9344a3eb7287962f18e3430f501af4d4db43397a2977267b15ef02a3bcd6e8ba384901e195672 languageName: node linkType: hard -"@swagger-api/apidom-ns-json-schema-draft-6@npm:^1.0.0-beta.5": - version: 1.0.0-beta.5 - resolution: "@swagger-api/apidom-ns-json-schema-draft-6@npm:1.0.0-beta.5" +"@swagger-api/apidom-ns-json-schema-2020-12@npm:^1.0.0-beta.11": + version: 1.0.0-beta.11 + resolution: "@swagger-api/apidom-ns-json-schema-2020-12@npm:1.0.0-beta.11" dependencies: "@babel/runtime-corejs3": "npm:^7.20.7" - "@swagger-api/apidom-core": "npm:^1.0.0-beta.5" - "@swagger-api/apidom-error": "npm:^1.0.0-beta.5" - "@swagger-api/apidom-ns-json-schema-draft-4": "npm:^1.0.0-beta.5" + "@swagger-api/apidom-core": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-error": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-ns-json-schema-2019-09": "npm:^1.0.0-beta.11" "@types/ramda": "npm:~0.30.0" ramda: "npm:~0.30.0" ramda-adjunct: "npm:^5.0.0" ts-mixer: "npm:^6.0.4" - checksum: 10/c439c3679dc6ea1807affa0a2913deea52ff8d63b48722c741458da147e849be9acc0c13955757a7b6904a4e7148cdb7845291da758a085083c61610a96fc36f + checksum: 10/847103b0923fa7a0fcff124f5359f735f56e38a88666051e5959403e0060e33bea63b30098dec99d36bcda6619b2a3d13ce61c26e6ca03c508ea8518e871c123 languageName: node linkType: hard -"@swagger-api/apidom-ns-json-schema-draft-7@npm:^1.0.0-beta.5": - version: 1.0.0-beta.5 - resolution: "@swagger-api/apidom-ns-json-schema-draft-7@npm:1.0.0-beta.5" +"@swagger-api/apidom-ns-json-schema-draft-4@npm:^1.0.0-beta.11, @swagger-api/apidom-ns-json-schema-draft-4@npm:^1.0.0-beta.5": + version: 1.0.0-beta.11 + resolution: "@swagger-api/apidom-ns-json-schema-draft-4@npm:1.0.0-beta.11" dependencies: "@babel/runtime-corejs3": "npm:^7.20.7" - "@swagger-api/apidom-core": "npm:^1.0.0-beta.5" - "@swagger-api/apidom-error": "npm:^1.0.0-beta.5" - "@swagger-api/apidom-ns-json-schema-draft-6": "npm:^1.0.0-beta.5" + "@swagger-api/apidom-ast": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-core": "npm:^1.0.0-beta.11" "@types/ramda": "npm:~0.30.0" ramda: "npm:~0.30.0" ramda-adjunct: "npm:^5.0.0" ts-mixer: "npm:^6.0.4" - checksum: 10/28c8f989e26a453f2ca6c655d6822d63f7bc57137375c460c707caa964a5097566969031f322d772b49de0ce2924aeb69e2c5a6ff66191b457844e68a40f013e + checksum: 10/9e0ba9e29849915c0067227a82d8d9a741221b3ac73592b73e23d3cfe0c57548b21cbbb66cdeba914218c5f675f4ec497232d465880013f8aec8f486e4c3d91c + languageName: node + linkType: hard + +"@swagger-api/apidom-ns-json-schema-draft-6@npm:^1.0.0-beta.11": + version: 1.0.0-beta.11 + resolution: "@swagger-api/apidom-ns-json-schema-draft-6@npm:1.0.0-beta.11" + dependencies: + "@babel/runtime-corejs3": "npm:^7.20.7" + "@swagger-api/apidom-core": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-error": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-ns-json-schema-draft-4": "npm:^1.0.0-beta.11" + "@types/ramda": "npm:~0.30.0" + ramda: "npm:~0.30.0" + ramda-adjunct: "npm:^5.0.0" + ts-mixer: "npm:^6.0.4" + checksum: 10/0df53880202cdb8dbe1e534314842c2c10fe4cd03f6eecd56d67174492807df03134f360e1c0724610fc4531520c49bb41d81e6fd7373118031ad883e0101338 + languageName: node + linkType: hard + +"@swagger-api/apidom-ns-json-schema-draft-7@npm:^1.0.0-beta.11, @swagger-api/apidom-ns-json-schema-draft-7@npm:^1.0.0-beta.5": + version: 1.0.0-beta.11 + resolution: "@swagger-api/apidom-ns-json-schema-draft-7@npm:1.0.0-beta.11" + dependencies: + "@babel/runtime-corejs3": "npm:^7.20.7" + "@swagger-api/apidom-core": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-error": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-ns-json-schema-draft-6": "npm:^1.0.0-beta.11" + "@types/ramda": "npm:~0.30.0" + ramda: "npm:~0.30.0" + ramda-adjunct: "npm:^5.0.0" + ts-mixer: "npm:^6.0.4" + checksum: 10/9ef5261e58b2e6798a3c22ad01940c613475a6515d2cd79d2f150b02152118036ef472bb75a271c0d1c3b617d51ccb2262680469824335f27150600f695eb6d8 languageName: node linkType: hard @@ -8125,36 +8157,37 @@ __metadata: languageName: node linkType: hard -"@swagger-api/apidom-ns-openapi-3-0@npm:^1.0.0-beta.3 <1.0.0-rc.0, @swagger-api/apidom-ns-openapi-3-0@npm:^1.0.0-beta.5": - version: 1.0.0-beta.5 - resolution: "@swagger-api/apidom-ns-openapi-3-0@npm:1.0.0-beta.5" +"@swagger-api/apidom-ns-openapi-3-0@npm:^1.0.0-beta.11, @swagger-api/apidom-ns-openapi-3-0@npm:^1.0.0-beta.3 <1.0.0-rc.0, @swagger-api/apidom-ns-openapi-3-0@npm:^1.0.0-beta.5": + version: 1.0.0-beta.11 + resolution: "@swagger-api/apidom-ns-openapi-3-0@npm:1.0.0-beta.11" dependencies: "@babel/runtime-corejs3": "npm:^7.20.7" - "@swagger-api/apidom-core": "npm:^1.0.0-beta.5" - "@swagger-api/apidom-error": "npm:^1.0.0-beta.5" - "@swagger-api/apidom-ns-json-schema-draft-4": "npm:^1.0.0-beta.5" + "@swagger-api/apidom-core": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-error": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-ns-json-schema-draft-4": "npm:^1.0.0-beta.11" "@types/ramda": "npm:~0.30.0" ramda: "npm:~0.30.0" ramda-adjunct: "npm:^5.0.0" ts-mixer: "npm:^6.0.3" - checksum: 10/5bb9c191a3d79d9a69aa04deb5dd6ce2eaa09700210f5a89fdeb0668ec76eb3f2d4a26950405f001459e59ad199ddd4632251992b9fc1b9fa03955e1ad600810 + checksum: 10/44eabda02fb8ad965b7756931bba7ac2ae9f49ef720f401179efb9eafbf94ff6b3c8cb8fd260c9ddb1affe35c3876f780cef4cb090b755736847adb7eba699c8 languageName: node linkType: hard -"@swagger-api/apidom-ns-openapi-3-1@npm:>=1.0.0-beta.3 <1.0.0-rc.0, @swagger-api/apidom-ns-openapi-3-1@npm:^1.0.0-beta.3 <1.0.0-rc.0, @swagger-api/apidom-ns-openapi-3-1@npm:^1.0.0-beta.5": - version: 1.0.0-beta.5 - resolution: "@swagger-api/apidom-ns-openapi-3-1@npm:1.0.0-beta.5" +"@swagger-api/apidom-ns-openapi-3-1@npm:>=1.0.0-beta.11 <1.0.0-rc.0, @swagger-api/apidom-ns-openapi-3-1@npm:^1.0.0-beta.3 <1.0.0-rc.0, @swagger-api/apidom-ns-openapi-3-1@npm:^1.0.0-beta.5": + version: 1.0.0-beta.11 + resolution: "@swagger-api/apidom-ns-openapi-3-1@npm:1.0.0-beta.11" dependencies: "@babel/runtime-corejs3": "npm:^7.20.7" - "@swagger-api/apidom-ast": "npm:^1.0.0-beta.5" - "@swagger-api/apidom-core": "npm:^1.0.0-beta.5" - "@swagger-api/apidom-json-pointer": "npm:^1.0.0-beta.5" - "@swagger-api/apidom-ns-openapi-3-0": "npm:^1.0.0-beta.5" + "@swagger-api/apidom-ast": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-core": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-json-pointer": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-ns-json-schema-2020-12": "npm:^1.0.0-beta.11" + "@swagger-api/apidom-ns-openapi-3-0": "npm:^1.0.0-beta.11" "@types/ramda": "npm:~0.30.0" ramda: "npm:~0.30.0" ramda-adjunct: "npm:^5.0.0" ts-mixer: "npm:^6.0.3" - checksum: 10/69018147465c78a25efc5e7bc4439561c2d620c65c9cf86bdc503dbad9b40e52db77bb0cb11800a4c34bdc88d462126e3dc355bad290cdfe7c9fbeda391405c8 + checksum: 10/6ed3a3d7bc231c8f3273c60dcd459cdb117246e372ed9d0d2483601a966a3109b9617a4d8f327f494a31013d5bcf2bc372c5f3297bbf953c61ff4770c87414a5 languageName: node linkType: hard @@ -8391,12 +8424,12 @@ __metadata: languageName: node linkType: hard -"@swagger-api/apidom-reference@npm:>=1.0.0-beta.3 <1.0.0-rc.0": - version: 1.0.0-beta.5 - resolution: "@swagger-api/apidom-reference@npm:1.0.0-beta.5" +"@swagger-api/apidom-reference@npm:>=1.0.0-beta.11 <1.0.0-rc.0": + version: 1.0.0-beta.11 + resolution: "@swagger-api/apidom-reference@npm:1.0.0-beta.11" dependencies: "@babel/runtime-corejs3": "npm:^7.20.7" - "@swagger-api/apidom-core": "npm:^1.0.0-beta.5" + "@swagger-api/apidom-core": "npm:^1.0.0-beta.11" "@swagger-api/apidom-error": "npm:^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-json-pointer": "npm:^1.0.0-beta.3 <1.0.0-rc.0" "@swagger-api/apidom-ns-asyncapi-2": "npm:^1.0.0-beta.3 <1.0.0-rc.0" @@ -8467,7 +8500,16 @@ __metadata: optional: true "@swagger-api/apidom-parser-adapter-yaml-1-2": optional: true - checksum: 10/e118278a4a460545de4e393aecaef16f92828cc0af02f4b9b3e8773b9f61c495e0afbce97e68ebd901858dc4b73a178aec2ec18fc1cec2a54d3ccbd3f6bc5331 + checksum: 10/1ae856fd2d13884f8f063421c010f166da1602075ae3b08c041c98199c724615b389110672e2c1b129b7b551a21b8738d32df87c066cc105669a03b6b21897e6 + languageName: node + linkType: hard + +"@swaggerexpert/cookie@npm:^1.4.1": + version: 1.4.1 + resolution: "@swaggerexpert/cookie@npm:1.4.1" + dependencies: + apg-lite: "npm:^1.0.3" + checksum: 10/936590cb70fb7af4ec988e7ee7c5e965b66d09046d3fd2b52c140f9777cda1452f2f24d80eb64ff9678b79f566dd0ef51699050aea9c9a3fd5345dcc73a3fa7a languageName: node linkType: hard @@ -8892,9 +8934,9 @@ __metadata: linkType: hard "@types/babel__preset-env@npm:^7": - version: 7.9.7 - resolution: "@types/babel__preset-env@npm:7.9.7" - checksum: 10/624425a84d9149aec04795fed6b1ac2f27dfd5d7976fde479bb1a4d754de34c92cdc28a1a373a5826382a68127b536420a0e090aa5fae522cb62724b7a571cb5 + version: 7.10.0 + resolution: "@types/babel__preset-env@npm:7.10.0" + checksum: 10/7d4d12758d89708afe327079d7d7580e8af3292295f087b8a9a48e12ac1d90aadc18ac3bc00f9b0cbc8778f3ce9fe778801d4d49b7691a75e3f13a901b69fd07 languageName: node linkType: hard @@ -9647,10 +9689,10 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:*, @types/lodash@npm:4.17.14, @types/lodash@npm:^4.14.172": - version: 4.17.14 - resolution: "@types/lodash@npm:4.17.14" - checksum: 10/6ee40725f3e192f5ef1f493caca19210aa7acd7adc3136b8dba84d418a35be0abea0668105aed9f696ad62a54310a9c0d328971ad4b157f5bcda700424ed5aae +"@types/lodash@npm:*, @types/lodash@npm:4.17.15, @types/lodash@npm:^4.14.172": + version: 4.17.15 + resolution: "@types/lodash@npm:4.17.15" + checksum: 10/27b348b5971b9c670215331b52448a13d7d65bf1fbd320a7049c9c153c1186ff5d116ba75f05f07d32d7ece8a992b26a30c7bdc9be22a3d1e4e3e6068aa04603 languageName: node linkType: hard @@ -9745,12 +9787,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:22.10.10, @types/node@npm:>=10.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=13.7.4, @types/node@npm:^22.0.0": - version: 22.10.10 - resolution: "@types/node@npm:22.10.10" +"@types/node@npm:*, @types/node@npm:22.12.0, @types/node@npm:>=10.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=13.7.4, @types/node@npm:^22.0.0": + version: 22.12.0 + resolution: "@types/node@npm:22.12.0" dependencies: undici-types: "npm:~6.20.0" - checksum: 10/61559b62bc7e62b947876e097c99472dd01317dd3b5916b538e2c40db8d68a1fc23c8de48149ef04cd2790a97bf2c005ea60cbb067e55ddbefd013d73da7a147 + checksum: 10/aac2b6f6a845ec3540c3d979b3150efe3162165bfda953af10b579df2d1cc4f5c48506922bf6bf661a2e5a7ebb571c5729bf1f9f12488a810bb1a5fa9522ef9d languageName: node linkType: hard @@ -10162,12 +10204,12 @@ __metadata: languageName: node linkType: hard -"@types/swagger-ui-react@npm:4.18.3": - version: 4.18.3 - resolution: "@types/swagger-ui-react@npm:4.18.3" +"@types/swagger-ui-react@npm:4.19.0": + version: 4.19.0 + resolution: "@types/swagger-ui-react@npm:4.19.0" dependencies: "@types/react": "npm:*" - checksum: 10/4927314f1b0d68edf200ef15bca7555f12ec1bb8cc699fa397ef2f4e1e1873d17880b663bbc6d70e30149e50c3e7055178c1e0aad110520dfb31d781ab326dcb + checksum: 10/c4a7ae85ca081bf079164b3e11ecc0c91dec1ed10da0f44fc67f8cf93a7d92d5bfa681035eb8b9d3327a0361d201861c02571b4b18f361253510f3ce19ce073f languageName: node linkType: hard @@ -10280,9 +10322,9 @@ __metadata: linkType: hard "@types/webpack-env@npm:^1.18.4": - version: 1.18.6 - resolution: "@types/webpack-env@npm:1.18.6" - checksum: 10/463e545441d206e52bf24b4577a503bf9a96198a69b3ad1234d15bf96e9a6d5ccb25bb1197e520541cb8da968232305fe8d8400595d5745e16abfdc4089e73d0 + version: 1.18.7 + resolution: "@types/webpack-env@npm:1.18.7" + checksum: 10/b07ca300b8e8af9ffad2bfdd6a662adffe49f86710a72cec01e4d10bb99444ed1ce45efbedda66ccd399b26ee22e7eadefd7c118a64047f5ea465b556ba86cf3 languageName: node linkType: hard @@ -11269,7 +11311,7 @@ __metadata: languageName: node linkType: hard -"apg-lite@npm:^1.0.3": +"apg-lite@npm:^1.0.3, apg-lite@npm:^1.0.4": version: 1.0.4 resolution: "apg-lite@npm:1.0.4" checksum: 10/9c5eb431497415b738e332e5805f836c64ac9b75a399afaec0354859f3f44c95e203fd5c7b8fee9f28fe1e184dd30f6073ed9df062849c3df03c51624901a8a6 @@ -17752,8 +17794,8 @@ __metadata: "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" "@grafana/saga-icons": "workspace:*" - "@grafana/scenes": "npm:5.38.0" - "@grafana/scenes-react": "npm:5.38.0" + "@grafana/scenes": "npm:5.41.0" + "@grafana/scenes-react": "npm:5.41.0" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/tsconfig": "npm:^2.0.0" @@ -17819,10 +17861,10 @@ __metadata: "@types/jquery": "npm:3.5.32" "@types/js-yaml": "npm:^4.0.5" "@types/jsurl": "npm:^1.2.28" - "@types/lodash": "npm:4.17.14" + "@types/lodash": "npm:4.17.15" "@types/logfmt": "npm:^1.2.3" "@types/lucene": "npm:^2" - "@types/node": "npm:22.10.10" + "@types/node": "npm:22.12.0" "@types/node-forge": "npm:^1" "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4" "@types/pluralize": "npm:^0.0.33" @@ -17844,7 +17886,7 @@ __metadata: "@types/slate": "npm:0.47.11" "@types/slate-plain-serializer": "npm:0.7.5" "@types/slate-react": "npm:0.22.9" - "@types/swagger-ui-react": "npm:4.18.3" + "@types/swagger-ui-react": "npm:4.19.0" "@types/systemjs": "npm:6.15.1" "@types/tinycolor2": "npm:1.4.6" "@types/uuid": "npm:10.0.0" @@ -17959,7 +18001,7 @@ __metadata: ml-regression-polynomial: "npm:^3.0.0" ml-regression-simple-linear: "npm:^3.0.0" moment: "npm:2.30.1" - moment-timezone: "npm:0.5.46" + moment-timezone: "npm:0.5.47" monaco-editor: "npm:0.34.1" moveable: "npm:0.53.0" msw: "npm:2.7.0" @@ -18032,7 +18074,7 @@ __metadata: style-loader: "npm:4.0.0" stylelint: "npm:16.13.2" stylelint-config-sass-guidelines: "npm:12.1.0" - swagger-ui-react: "npm:5.18.2" + swagger-ui-react: "npm:5.18.3" symbol-observable: "npm:4.0.0" systemjs: "npm:6.15.1" systemjs-cjs-extra: "npm:0.2.1" @@ -22433,6 +22475,15 @@ __metadata: languageName: node linkType: hard +"moment-timezone@npm:0.5.47": + version: 0.5.47 + resolution: "moment-timezone@npm:0.5.47" + dependencies: + moment: "npm:^2.29.4" + checksum: 10/b2ad32e6b7ea4ce99756c9f571f8eb1bdd6592ecdc2bf3f7fb988ae980a49a7e87b986776090571b9608c8576205f839b736dc7589c180ce36c82fad4b8416c6 + languageName: node + linkType: hard + "moment@npm:2.30.1, moment@npm:2.x, moment@npm:^2.20.1, moment@npm:^2.29.4, moment@npm:^2.30.1": version: 2.30.1 resolution: "moment@npm:2.30.1" @@ -23434,21 +23485,21 @@ __metadata: languageName: node linkType: hard -"openapi-path-templating@npm:^1.5.1": - version: 1.6.0 - resolution: "openapi-path-templating@npm:1.6.0" +"openapi-path-templating@npm:^2.0.1": + version: 2.1.0 + resolution: "openapi-path-templating@npm:2.1.0" dependencies: - apg-lite: "npm:^1.0.3" - checksum: 10/35353a0ce712dd79b7a60beca75d6a67fb6ad4bbdac4ceef87a6ab78d916f88dd6b426bd81e8ecf12c51798221fb15e63da8f9cadb1bcab4bc7aace8e8a266c1 + apg-lite: "npm:^1.0.4" + checksum: 10/de3ba30a19cc4bed5ace5dad0314bea66e09689001bd3510224a441f7ced53d854655daea846f884ed024d90a8f2af2d4bd0f28256dbda0c49b337684a802da1 languageName: node linkType: hard -"openapi-server-url-templating@npm:^1.0.0": - version: 1.1.0 - resolution: "openapi-server-url-templating@npm:1.1.0" +"openapi-server-url-templating@npm:^1.2.0": + version: 1.3.0 + resolution: "openapi-server-url-templating@npm:1.3.0" dependencies: - apg-lite: "npm:^1.0.3" - checksum: 10/932f08f390269506e1ea1d24208319f0f2a90023368562f1ce167a30d4199f0e39236e17f250749359b4c7b64d86c510e68e8eb9c7f6168da35f0475534ef3ed + apg-lite: "npm:^1.0.4" + checksum: 10/4a98f67cedc0958d3a30cc6db91f35970b0aeae113d0403639adbe3f5e123ff02abebc0468c10639bd0d6db165a9bb0f21c4e367836e984c2345a5a4213620a9 languageName: node linkType: hard @@ -23759,10 +23810,10 @@ __metadata: languageName: node linkType: hard -"papaparse@npm:5.5.1": - version: 5.5.1 - resolution: "papaparse@npm:5.5.1" - checksum: 10/6c3b47bd316cf11936641abe5590c6fb1e5b04297d1c594965a44dba89cc0f26b4e4d61eb1933352c538a30c4915d110628d14cf4d60cef0b8245c9a26477602 +"papaparse@npm:5.5.2": + version: 5.5.2 + resolution: "papaparse@npm:5.5.2" + checksum: 10/f5d25ccb10b1b9e87a0fe2d2965c7f8a83223cd79ec8963dd7e8766e8e97a6228dc6c20738a78148dfc74f32b2372003de732a7d50593f38b6a2bb5092710bc7 languageName: node linkType: hard @@ -29364,35 +29415,35 @@ __metadata: languageName: node linkType: hard -"swagger-client@npm:^3.31.0": - version: 3.32.2 - resolution: "swagger-client@npm:3.32.2" +"swagger-client@npm:^3.34.0": + version: 3.34.0 + resolution: "swagger-client@npm:3.34.0" dependencies: "@babel/runtime-corejs3": "npm:^7.22.15" "@scarf/scarf": "npm:=1.4.0" - "@swagger-api/apidom-core": "npm:>=1.0.0-beta.3 <1.0.0-rc.0" - "@swagger-api/apidom-error": "npm:>=1.0.0-beta.3 <1.0.0-rc.0" - "@swagger-api/apidom-json-pointer": "npm:>=1.0.0-beta.3 <1.0.0-rc.0" - "@swagger-api/apidom-ns-openapi-3-1": "npm:>=1.0.0-beta.3 <1.0.0-rc.0" - "@swagger-api/apidom-reference": "npm:>=1.0.0-beta.3 <1.0.0-rc.0" - cookie: "npm:~0.7.2" + "@swagger-api/apidom-core": "npm:>=1.0.0-beta.11 <1.0.0-rc.0" + "@swagger-api/apidom-error": "npm:>=1.0.0-beta.11 <1.0.0-rc.0" + "@swagger-api/apidom-json-pointer": "npm:>=1.0.0-beta.11 <1.0.0-rc.0" + "@swagger-api/apidom-ns-openapi-3-1": "npm:>=1.0.0-beta.11 <1.0.0-rc.0" + "@swagger-api/apidom-reference": "npm:>=1.0.0-beta.11 <1.0.0-rc.0" + "@swaggerexpert/cookie": "npm:^1.4.1" deepmerge: "npm:~4.3.0" fast-json-patch: "npm:^3.0.0-1" js-yaml: "npm:^4.1.0" neotraverse: "npm:=0.6.18" node-abort-controller: "npm:^3.1.1" node-fetch-commonjs: "npm:^3.3.2" - openapi-path-templating: "npm:^1.5.1" - openapi-server-url-templating: "npm:^1.0.0" + openapi-path-templating: "npm:^2.0.1" + openapi-server-url-templating: "npm:^1.2.0" ramda: "npm:^0.30.1" ramda-adjunct: "npm:^5.0.0" - checksum: 10/651f8e6446b57c42de61684440e854f146c9e6a0d9eab28892d07a00bc0dd9e742205c285d4db2dea85d657c62da3a81a44cf417db2383753a75d69377877253 + checksum: 10/ed89c44ba172abb9cd6a36a189cf2810ef9b1983cb71657772ede94ea804105b07549b90ba36bbead87e4ac71e609b51f0977d9f9c90112952756c7f4f8bde82 languageName: node linkType: hard -"swagger-ui-react@npm:5.18.2": - version: 5.18.2 - resolution: "swagger-ui-react@npm:5.18.2" +"swagger-ui-react@npm:5.18.3": + version: 5.18.3 + resolution: "swagger-ui-react@npm:5.18.3" dependencies: "@babel/runtime-corejs3": "npm:^7.24.7" "@braintree/sanitize-url": "npm:=7.0.4" @@ -29423,7 +29474,7 @@ __metadata: reselect: "npm:^5.1.1" serialize-error: "npm:^8.1.0" sha.js: "npm:^2.4.11" - swagger-client: "npm:^3.31.0" + swagger-client: "npm:^3.34.0" url-parse: "npm:^1.5.10" xml: "npm:=1.0.1" xml-but-prettier: "npm:^1.0.1" @@ -29431,7 +29482,7 @@ __metadata: peerDependencies: react: ">=16.8.0 <19" react-dom: ">=16.8.0 <19" - checksum: 10/04d4bd8e67ac19472d7915ed779933108b660babd14740c74efa33c76a6bd17e94ebcabd4449e365da3527937f3c60f53c1c392c3e13e5ac5f2e984e6cdcee42 + checksum: 10/4437fc3fd9a869ab3ef357c78137e20fc9d96dbc71332e9ac53b45decd41382c9467d610ebf0c15a628485c67475a8f89c37882ca548305d633e2dd4b632db5f languageName: node linkType: hard