Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Ryan McKinley committed 2025-01-28 21:05:18 +03:00
commit d357a112e2
257 files changed
+3808 -2081

No files matched your search

+30 -50
View File
@@ -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 <Trans /> 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 <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
],
"public/app/features/alerting/unified/CloneRuleEditor.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"]
],
"public/app/features/alerting/unified/ExistingRuleEditor.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 <Trans /> or use t()", "1"],
@@ -1612,12 +1596,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"]
],
"public/app/features/alerting/unified/RuleEditor.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/alerting/unified/RuleViewer.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],
@@ -2502,12 +2480,11 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
],
"public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
@@ -2570,7 +2547,8 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 <Trans /> 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 <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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 <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
],
"public/app/features/alerting/unified/rule-editor/RuleEditor.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/alerting/unified/rule-list/FilterView.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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 <Trans /> or use t()", "2"]
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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 <Trans /> 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 <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"]
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]
],
"public/app/features/explore/ExploreToolbar.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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 <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
@@ -6364,8 +6350,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 <Trans />", "1"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
@@ -6408,8 +6390,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 <Trans /> or use t()", "1"]
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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"]
-1
View File
@@ -179,7 +179,6 @@ compilation-stats.json
/blob-report/
/playwright/.cache/
/playwright/.auth/
# grafana server
/scripts/grafana-server/server.log
+115
View File
@@ -1,3 +1,118 @@
<!-- 11.4.1 START -->
# 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.4.1 END -->
<!-- 11.3.3 START -->
# 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.3.3 END -->
<!-- 11.2.6 START -->
# 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.2.6 END -->
<!-- 11.1.11 START -->
# 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.1.11 END -->
<!-- 11.0.10 START -->
# 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)
<!-- 11.0.10 END -->
<!-- 10.4.15 START -->
# 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)
<!-- 10.4.15 END -->
<!-- 11.4.0 START -->
# 11.4.0 (2024-12-05)
+3
View File
@@ -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.
@@ -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.
@@ -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)**
@@ -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 |
@@ -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="<GRAFANA_VERSION>" >}}
{{< docs/shared lookup="back-up/back-up-grafana.md" source="grafana" version="<GRAFANA_VERSION>" leveloffset="+1" >}}
{{< docs/shared lookup="upgrade/upgrade-common-tasks.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Technical notes
+1
View File
@@ -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/<GRAFANA_VERSION>/whatsnew/whats-new-in-v11-5/)
- [What's new in 11.4](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/whatsnew/whats-new-in-v11-4/)
- [What's new in 11.3](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/whatsnew/whats-new-in-v11-3/)
- [What's new in 11.2](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/whatsnew/whats-new-in-v11-2/)
@@ -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/<GRAFANA_VERSION>/upgrade-guide/upgrade-v11.5/).
{{< docs/whats-new >}}
+9 -7
View File
@@ -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",
+4 -4
View File
@@ -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",
@@ -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,
@@ -228,6 +228,7 @@ export interface GrafanaConfig {
rudderstackConfigUrl: string | undefined;
rudderstackIntegrationsUrl: string | undefined;
analyticsConsoleReporting: boolean;
dashboardPerformanceMetrics: string[];
sqlConnectionLimits: SqlConnectionLimits;
sharedWithMeFolderUID?: string;
rootFolderUID?: string;
@@ -229,7 +229,6 @@ export interface FeatureToggles {
exploreMetricsRelatedLogs?: boolean;
prometheusSpecialCharsInLabelValues?: boolean;
enableExtensionsAdminPage?: boolean;
zipkinBackendMigration?: boolean;
enableSCIM?: boolean;
crashDetection?: boolean;
jaegerBackendMigration?: boolean;
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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",
@@ -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 {
@@ -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 {
@@ -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';
@@ -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';
@@ -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';
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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",
+1
View File
@@ -185,6 +185,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
rudderstackConfigUrl: undefined;
rudderstackIntegrationsUrl: undefined;
analyticsConsoleReporting = false;
dashboardPerformanceMetrics: string[] = [];
sqlConnectionLimits = {
maxOpenConns: 100,
maxIdleConns: 100,
+1 -1
View File
@@ -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,
@@ -66,11 +66,13 @@ export function logError(err: Error, contexts?: LogContext) {
export type MeasurementValues = Record<string, number>;
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 }
);
}
}
+3 -3
View File
@@ -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",
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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 {
@@ -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';
@@ -12,6 +12,7 @@ import { SelectRow } from './SelectRow';
// Mock featureToggle sqlQuerybuilderFunctionParameters
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
featureToggles: {
sqlQuerybuilderFunctionParameters: true,
@@ -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';
@@ -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';
@@ -17,7 +17,7 @@ import {
VariableWithMultiSupport,
TimeRange,
} from '@grafana/data';
import { EditorMode } from '@grafana/experimental';
import { EditorMode } from '@grafana/plugin-ui';
import {
BackendDataSourceResponse,
DataSourceWithBackend,
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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",
@@ -162,15 +162,15 @@ export const AsyncOptionsWithLabels: Story = {
return (
<Field
label='Asynbc options fn returns objects like { label: "Option 69", value: "69" }'
label='Async options fn returns objects like { label: "Option 69", value: "69" }'
description="Search for 'break' to see an error"
>
<Combobox
{...args}
{...dynamicArgs}
onChange={(val: ComboboxOption | null) => {
onChangeAction(val);
setArgs({ value: val });
onChange={(value: ComboboxOption | null) => {
onChangeAction(value);
setArgs({ value });
}}
/>
</Field>
@@ -205,7 +205,7 @@ export const AsyncOptionsWithOnlyValues: Story = {
{...dynamicArgs}
onChange={(value: ComboboxOption | null) => {
onChangeAction(value);
setArgs({ value: value });
setArgs({ value });
}}
/>
</Field>
@@ -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<typeof MultiCombobox> = {
@@ -11,6 +13,9 @@ const meta: Meta<typeof MultiCombobox> = {
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 (
<MultiCombobox
{...args}
value={value}
onChange={(val) => {
onChangeAction(val);
setArgs({ value: val });
}}
/>
);
},
};
export const AutoSize: Story = {
args: { ...commonArgs, width: 'auto', minWidth: 20 },
render: (args) => {
const [{ value }, setArgs] = useArgs();
return (
<MultiCombobox
{...args}
@@ -49,17 +72,14 @@ export const Basic: Story = {
};
const ManyOptionsStory: StoryFn<ManyOptionsArgs> = ({ numberOfOptions = 1e4, ...args }) => {
const [value, setValue] = useState<string[]>([]);
const [dynamicArgs, setArgs] = useArgs();
const [options, setOptions] = useState<ComboboxOption[]>([]);
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<ManyOptionsArgs> = ({ numberOfOptions = 1e4, ...
return (
<MultiCombobox
{...rest}
loading={isLoading}
{...dynamicArgs}
options={options}
value={value}
onChange={(opts) => {
setValue(opts || []);
action('onChange')(opts);
setArgs({ value: opts });
onChangeAction(opts);
}}
/>
);
@@ -86,3 +105,71 @@ export const ManyOptions: StoryObj<ManyOptionsArgs> = {
},
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 (
<Field
label='Asynbc options fn returns objects like { label: "Option 69", value: "69" }'
description="Search for 'break' to see an error"
>
<MultiCombobox
{...args}
{...dynamicArgs}
onChange={(val) => {
onChangeAction(val);
setArgs({ value: val });
}}
/>
</Field>
);
},
};
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 (
<Field
label='Async options fn returns objects like { value: "69" }'
description="Search for 'break' to see an error"
>
<MultiCombobox
{...args}
{...dynamicArgs}
onChange={(val) => {
onChangeAction(val);
setArgs({ value: val });
}}
/>
</Field>
);
},
};
@@ -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<typeof userEvent.setup>;
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(<MultiCombobox options={asyncOptions} value={[]} onChange={onChangeHandler} />);
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(<MultiCombobox options={asyncOptions} value={[]} onChange={onChangeHandler} />);
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(<MultiCombobox options={asyncOptions} value={[{ value: 'Option 69' }]} onChange={onChangeHandler} />);
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(<MultiCombobox options={asyncOptions} value={[]} onChange={onChangeHandler} />);
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(<MultiCombobox options={asyncOptions} value={[]} onChange={onChangeHandler} />);
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<ComboboxOption[]>((resolve) => setTimeout(() => resolve(value), timeout));
}
@@ -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<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
value?: T[] | Array<ComboboxOption<T>>;
onChange: (items?: T[]) => void;
onChange: (option: Array<ComboboxOption<T>>) => void;
enableAllOption?: boolean;
}
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
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<T>(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 = <T extends string | number>(props: MultiComboboxPro
} as ComboboxOption<T>;
}, [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<T>(value, baseOptions);
}, [value, baseOptions]);
const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti(
selectedItems,
@@ -85,51 +80,53 @@ export const MultiCombobox = <T extends string | number>(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 = <T extends string | number>(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 = <T extends string | number>(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 = <T extends string | number>(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 = <T extends string | number>(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 (
<div ref={containerRef}>
<div
style={{ width: width === 'auto' ? undefined : width }}
className={cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled })}
ref={measureRef}
>
<div className={multiStyles.container} ref={containerRef}>
<div className={cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled })} ref={measureRef}>
<span className={multiStyles.pillWrapper}>
{visibleItems.map((item, index) => (
<ValuePill
@@ -258,15 +276,16 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
getDropdownProps({
disabled,
preventKeyAction: isOpen,
placeholder: selectedItems.length > 0 ? undefined : placeholder,
placeholder,
ref: inputRef,
style: { width: inputWidth },
})
)}
/>
{loading && (
<div className={multiStyles.suffix} ref={suffixMeasureRef}>
<Spinner inline={true} />
</div>
)}
<div className={multiStyles.suffix} ref={suffixMeasureRef} {...getToggleButtonProps()}>
<SuffixIcon isLoading={loading || false} isOpen={isOpen} />
</div>
</span>
</div>
<Portal>
@@ -280,13 +299,16 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
{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 (
<li
@@ -310,7 +332,7 @@ export const MultiCombobox = <T extends string | number>(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 = <T extends string | number>(props: MultiComboboxPro
);
})}
</ul>
<div aria-live="polite">{items.length === 0 && <NotFoundError />}</div>
<div aria-live="polite">{options.length === 0 && <NotFoundError />}</div>
</ScrollContainer>
)}
</div>
@@ -364,7 +386,3 @@ function isComboboxOptions<T extends string | number>(
): value is Array<ComboboxOption<T>> {
return typeof value[0] === 'object';
}
function getComboboxOptionsValues<T extends string | number>(optionArray: Array<ComboboxOption<T>>) {
return optionArray.map((option) => option.value);
}
@@ -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 <Icon name={suffixIcon} />;
};
@@ -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,
},
@@ -0,0 +1,33 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { measureText } from '../../utils';
export function useMultiInputAutoSize(inputValue: string) {
const inputRef = useRef<HTMLInputElement>(null);
const initialInputWidth = useRef<number>(0); // Store initial width to prevent resizing on backspace
const [inputWidth, setInputWidth] = useState<string>('');
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 };
}
@@ -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<T extends string | number> =
| Array<ComboboxOption<T>>
| ((inputValue: string) => Promise<Array<ComboboxOption<T>>>);
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<T extends string | number>(rawOptions: AsyncOptions<T>) {
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<Array<ComboboxOption<T>>>([]);
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 };
}
@@ -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', {
+21 -13
View File
@@ -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()
+6 -6
View File
@@ -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)
+2 -2
View File
@@ -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"`
}
+2
View File
@@ -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"`
+1
View File
@@ -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,
+1 -1
View File
@@ -34,7 +34,7 @@ type Options struct {
type DiffTarget struct {
DashboardId int64
Version int
Version int64
UnsavedDashboard *simplejson.Json
}
@@ -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 }}
@@ -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(),
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
}
@@ -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))
}
-49
View File
@@ -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)
}
+33 -4
View File
@@ -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 {
+11 -2
View File
@@ -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)
}
@@ -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)
})
}
@@ -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]
}
@@ -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)
})
}
+1 -1
View File
@@ -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)
}
@@ -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
}
@@ -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 {
@@ -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,
}
@@ -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
}
+18 -6
View File
@@ -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"`
}
-7
View File
@@ -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",
-1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
210 exploreMetricsRelatedLogs experimental @grafana/observability-metrics false false true
211 prometheusSpecialCharsInLabelValues experimental @grafana/oss-big-tent false false true
212 enableExtensionsAdminPage experimental @grafana/plugins-platform-backend false true false
zipkinBackendMigration GA @grafana/oss-big-tent false false false
213 enableSCIM experimental @grafana/identity-access-team false false false
214 crashDetection experimental @grafana/observability-traces-and-profiling false false true
215 jaegerBackendMigration experimental @grafana/oss-big-tent false false false
-4
View File
@@ -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"
@@ -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"
}
@@ -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]
}
@@ -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)
}
+1 -3
View File
@@ -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
@@ -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{
+18 -15
View File
@@ -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
@@ -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))
@@ -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) {
+7 -1
View File
@@ -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
}
@@ -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)
}
@@ -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)
}
@@ -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) {
+5 -5
View File
@@ -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
@@ -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))
})
})
+5 -3
View File
@@ -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
+15 -5
View File
@@ -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
}
@@ -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)
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff. Show more