Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
commit
d357a112e2
257 files changed
+3808
-2081
No files matched your search
+30
-50
@@ -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"]
|
||||
|
||||
@@ -179,7 +179,6 @@ compilation-stats.json
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
|
||||
# grafana server
|
||||
/scripts/grafana-server/server.log
|
||||
|
||||
|
||||
+115
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -185,6 +185,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
||||
rudderstackConfigUrl: undefined;
|
||||
rudderstackIntegrationsUrl: undefined;
|
||||
analyticsConsoleReporting = false;
|
||||
dashboardPerformanceMetrics: string[] = [];
|
||||
sqlConnectionLimits = {
|
||||
maxOpenConns: 100,
|
||||
maxIdleConns: 100,
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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,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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+2
-2
@@ -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
|
||||
Vendored
+2
-2
@@ -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
|
||||
Vendored
+2
-2
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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]
|
||||
}
|
||||
+14
-4
@@ -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)
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user