Compare commits

...

16 Commits

Author SHA1 Message Date
Konrad Lalik fbc2877f1b Alerting: Clean up dangling references when queries are removed
When a data query or expression is deleted, other expressions that
reference it would contain invalid/dangling references, potentially
causing errors or unexpected behavior.

This change introduces automatic cleanup of dangling references across
all expression types:

- Math expressions: Removes variable references ($A, ${A}) from the
  expression string
- Reduce/Resample/Threshold: Clears the expression field if it
  references the removed query
- Classic conditions: Filters out removed refIds from params array
- Handles cascading removals when expressions reference other
  expressions

The cleanup is triggered in two scenarios:
1. When explicitly removing an expression via removeExpression action
2. When updating data queries via setDataQueries (e.g., user deletes
   a query in the query editor)

Also migrates the Reduce component from Select to Combobox for better
UX consistency, with improved layout spacing.
2026-01-07 10:50:56 +01:00
Konrad Lalik dac46eb41f Alerting: Clear dangling references when removing queries or expressions
Fixes an issue where removing a query or expression that was referenced by other expressions would leave dangling references, causing DAG creation errors and UI inconsistencies.

When a query/expression is removed, all expressions that reference it now have their references automatically cleared:
- Reduce/threshold/resample expressions: Set expression field to null
- Math expressions: Remove $refId and ${refId} patterns from expression string
- Classic conditions: Remove refId from params array

This prevents console errors and ensures UI components (like Select dropdowns) properly display "no selection" instead of showing invalid references.
2025-12-17 16:58:21 +01:00
Victor Cinaglia fe49ae05c0 Auth: Disable login prompt option for Google OAuth when "use_refresh_token" is enabled (#115367)
* Auth: Google OAuth consent prompt takes precedence when use_refresh_token is true

* Auth: Disable login prompt option for Google OAuth when use_refresh_token is true

* yarn run prettier:check --write

* feedback: validate login prompt when use_refresh_token is true
2025-12-17 09:03:29 -03:00
Ryan McKinley d02b2a35cd Provisioning: Ignore dashboard change warning after save (#115401) 2025-12-17 10:17:57 +00:00
Kevin Minehart e4202db28f CI: enable branch cleanup workflow (#115470)
enable branch cleanup workflow
2025-12-17 10:44:06 +01:00
Oleg Zaytsev 015219e49f Logs Panel: Integrate client-side search with Popover Menu (#114653)
* Explore: Add custom text highlighting to logs panel

Add ability to select text in log lines and highlight all occurrences
with persistent colors. Highlights are stored in URL state and cycle
through the theme's visualization palette.

- Add CustomHighlight type to ExploreLogsPanelState
- Implement LogListHighlightContext for state management
- Generate custom highlight grammar using Prism.js tokens
- Add "Highlight occurrences" option to popover menu
- Add "Reset highlights" control when highlights exist
- Fix pruneObject to preserve colorIndex: 0 in URL state

* Fix CI failures: formatting and i18n extraction

- Run prettier on LogLine.tsx
- Run i18n-extract to update translation strings

* Fix lint errors

- Use theme.shape.radius.default instead of literal '2px' in LogLine.tsx
- Remove unnecessary type assertion in grammar.ts

* Fix TypeScript error in grammar.ts

Use Record<string, GrammarValue> type for dynamic grammar object to allow string indexing without type assertions.

* Replace hardcoded HIGHLIGHT_COLOR_COUNT with actual theme palette length

Use useTheme2() hook to dynamically get the palette length instead of
hardcoding it to 50. This ensures the color cycling works correctly
regardless of the actual theme palette size.

* Backtrack to a stable point and revert changes

* Implement using search

* New translations

* LogListSearch: refactor search state

* PopoverMenu: add divider

* LogLine: remove padding and update border radius

* LogListSearch: add missing tooltips

* Refactor keybindings

* More cleanup

* LogListSearch: don't autoscroll with filterLogs

---------

Co-authored-by: Matias Chomicki <matyax@gmail.com>
2025-12-17 10:43:50 +01:00
Matias Chomicki 1b9e0fae8d Logs: more analytics (#115330)
* Logs: more analytics

* LogLineDetails: collect fields data

* analytics: report length count

* Prettier

* LogLineDetailsHeader: track details mode toggle
2025-12-17 10:25:28 +01:00
Ashley Harrison fc4c699d85 Chore: More backwards compatible changes needed for react 19 (#115422)
backwards compatible changes needed for react 19
2025-12-17 09:21:39 +00:00
Rafael Bortolon Paulovic aa3b9dc4da Unified: Run resource data migrations at startup (#114857)
* chore: uncomment unified migration

* chore: adapt and fix tests

* chore: dynamically bump max conns if needed during migration

* chore: copilot suggestions

* chore: pass ctx in RegisterMigration

* chore: make playlists opt-out and dashboards opt-in

* chore: adjust dashboard test

* chore: disable enable log in test

* chore: address review comments

- do not use pointer config
- add migration registry

* chore: more consistent naming

* chore: fix playlist discovery test
2025-12-17 10:09:57 +01:00
Torkel Ödegaard 00ea4024a8 ControlsMenu: Fix button spacing (#115468) 2025-12-17 08:36:34 +01:00
Erik Sundell ee483a816c E2E Selectors: Fix test title typo (#115418)
fix typo
2025-12-17 08:20:46 +01:00
Erik Sundell 931806d1e9 NPM: Dispatch to plugin-tools on e2e-selectors changes (#115218) 2025-12-17 08:10:26 +01:00
dependabot[bot] 8dd63baf50 deps(go): bump the k8s-io group across 2 directories with 2 updates (#115464)
* deps(go): bump the k8s-io group across 2 directories with 2 updates

Bumps the k8s-io group with 1 update in the / directory: [k8s.io/kube-aggregator](https://github.com/kubernetes/kube-aggregator).
Bumps the k8s-io group with 1 update in the /hack directory: [k8s.io/code-generator](https://github.com/kubernetes/code-generator).


Updates `k8s.io/kube-aggregator` from 0.34.2 to 0.34.3
- [Commits](https://github.com/kubernetes/kube-aggregator/compare/v0.34.2...v0.34.3)

Updates `k8s.io/code-generator` from 0.34.2 to 0.34.3
- [Commits](https://github.com/kubernetes/code-generator/compare/v0.34.2...v0.34.3)

---
updated-dependencies:
- dependency-name: k8s.io/kube-aggregator
  dependency-version: 0.34.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: k8s-io
- dependency-name: k8s.io/code-generator
  dependency-version: 0.34.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: k8s-io
...

Signed-off-by: dependabot[bot] <support@github.com>

* update all

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
2025-12-17 07:08:40 +00:00
Hugo Häggmark 0474c8f1d5 Chore: bump @grafana/plugin-e2e (#115466) 2025-12-17 07:46:02 +01:00
Torkel Ödegaard e224793eba Dashboard: Hide sidebar when playlist is playing (#115414) 2025-12-17 07:24:25 +01:00
dependabot[bot] 7620c1f24f deps(go): bump github.com/expr-lang/expr from 1.17.6 to 1.17.7 in /pkg/codegen (#115460)
* deps(go): bump github.com/expr-lang/expr in /pkg/codegen

Bumps [github.com/expr-lang/expr](https://github.com/expr-lang/expr) from 1.17.6 to 1.17.7.
- [Release notes](https://github.com/expr-lang/expr/releases)
- [Commits](https://github.com/expr-lang/expr/compare/v1.17.6...v1.17.7)

---
updated-dependencies:
- dependency-name: github.com/expr-lang/expr
  dependency-version: 1.17.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* use the same version everywhere

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
2025-12-17 06:19:03 +00:00
91 changed files with 1527 additions and 463 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ require (
github.com/cockroachdb/apd/v3 v3.2.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/proto v1.13.2 // indirect
github.com/expr-lang/expr v1.17.0 // indirect
github.com/expr-lang/expr v1.17.7 // indirect
github.com/getkin/kin-openapi v0.132.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
+1 -1
View File
@@ -14,5 +14,5 @@ jobs:
- uses: actions/checkout@v5
- uses: grafana/shared-workflows/actions/cleanup-branches@cleanup-branches/v0.2.1
with:
dry-run: true
dry-run: false
max-date: "1 month ago"
+41
View File
@@ -90,6 +90,7 @@ jobs:
with:
persist-credentials: false
ref: ${{ inputs.grafana_commit }}
fetch-depth: 2 # Need HEAD~1 for e2e-selectors change detection
- name: Setup Node
uses: ./.github/actions/setup-node
@@ -127,3 +128,43 @@ jobs:
env:
NPM_TAG: ${{ steps.npm-tag.outputs.NPM_TAG }}
run: ./scripts/publish-npm-packages.sh --dist-tag "$NPM_TAG" --registry 'https://registry.npmjs.org/'
# Notify plugin-tools when e2e-selectors changes so it can update its bundled version
- name: Check for e2e-selectors changes
id: check-e2e-changes
run: |
CHANGES=$(git diff --name-only HEAD~1 HEAD -- packages/grafana-e2e-selectors | wc -l)
echo "changes=$CHANGES" >> "$GITHUB_OUTPUT"
if [ "$CHANGES" -gt 0 ]; then
echo "Detected $CHANGES file(s) changed in packages/grafana-e2e-selectors"
fi
- name: Get Vault secrets
if: steps.check-e2e-changes.outputs.changes > 0
id: vault-secrets
uses: grafana/shared-workflows/actions/get-vault-secrets@main
with:
repo_secrets: |
GRAFANA_DELIVERY_BOT_APP_PEM=delivery-bot-app:PRIVATE_KEY
- name: Generate token for plugin-tools
if: steps.check-e2e-changes.outputs.changes > 0
id: generate_token
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
with:
app_id: ${{ vars.DELIVERY_BOT_APP_ID }}
private_key: ${{ env.GRAFANA_DELIVERY_BOT_APP_PEM }}
repositories: '["plugin-tools"]'
permissions: '{"actions": "write"}'
- name: Dispatch to plugin-tools
if: steps.check-e2e-changes.outputs.changes > 0
env:
VERSION: ${{ inputs.version }}
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
run: |
echo "Dispatching bump-e2e-selectors workflow to grafana/plugin-tools with version $VERSION"
gh workflow run bump-e2e-selectors.yml \
--repo grafana/plugin-tools \
--ref main \
--field version="$VERSION"
+2 -2
View File
@@ -2287,8 +2287,8 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kms v0.34.3 h1:QzBOD0sk1bGQVMcZQAHGjtbP1iKZJUyhC6D0I+BTxIE=
k8s.io/kms v0.34.3/go.mod h1:s1CFkLG7w9eaTYvctOxosx88fl4spqmixnNpys0JAtM=
k8s.io/kube-aggregator v0.34.2 h1:Nn0Vksj67WHBL2x7bJ6vuxL44RbMTK6uRtXX+3vMVJk=
k8s.io/kube-aggregator v0.34.2/go.mod h1:/tp4cc/1p2AvICsS4mjjSJakdrbhcGbRmj0mdHTdR2Q=
k8s.io/kube-aggregator v0.34.3 h1:rKsZWTD2As4dKuv+zzdJU0uo5H7bFlAEoSucai4mW6M=
k8s.io/kube-aggregator v0.34.3/go.mod h1:d4D8PV2FK4Qlq6u442FSum1tHPhK9tKdKBfH/A3R0I0=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
-11
View File
@@ -261,18 +261,7 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk=
k8s.io/apiextensions-apiserver v0.34.2 h1:WStKftnGeoKP4AZRz/BaAAEJvYp4mlZGN0UCv+uvsqo=
k8s.io/apiextensions-apiserver v0.34.2/go.mod h1:398CJrsgXF1wytdaanynDpJ67zG4Xq7yj91GrmYN2SE=
k8s.io/apiextensions-apiserver v0.34.3/go.mod h1:aujxvqGFRdb/cmXYfcRTeppN7S2XV/t7WMEc64zB5A0=
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
-5
View File
@@ -763,11 +763,6 @@
"count": 1
}
},
"packages/grafana-ui/src/components/Select/resetSelectStyles.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"packages/grafana-ui/src/components/Select/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6
+1 -1
View File
@@ -221,7 +221,7 @@ require (
k8s.io/client-go v0.34.3 // @grafana/grafana-app-platform-squad
k8s.io/component-base v0.34.3 // @grafana/grafana-app-platform-squad
k8s.io/klog/v2 v2.130.1 // @grafana/grafana-app-platform-squad
k8s.io/kube-aggregator v0.34.2 // @grafana/grafana-app-platform-squad
k8s.io/kube-aggregator v0.34.3 // @grafana/grafana-app-platform-squad
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // @grafana/grafana-app-platform-squad
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // @grafana/partner-datasources
modernc.org/sqlite v1.40.1 // @grafana/grafana-backend-group
+2 -2
View File
@@ -3694,8 +3694,8 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kms v0.34.3 h1:QzBOD0sk1bGQVMcZQAHGjtbP1iKZJUyhC6D0I+BTxIE=
k8s.io/kms v0.34.3/go.mod h1:s1CFkLG7w9eaTYvctOxosx88fl4spqmixnNpys0JAtM=
k8s.io/kube-aggregator v0.34.2 h1:Nn0Vksj67WHBL2x7bJ6vuxL44RbMTK6uRtXX+3vMVJk=
k8s.io/kube-aggregator v0.34.2/go.mod h1:/tp4cc/1p2AvICsS4mjjSJakdrbhcGbRmj0mdHTdR2Q=
k8s.io/kube-aggregator v0.34.3 h1:rKsZWTD2As4dKuv+zzdJU0uo5H7bFlAEoSucai4mW6M=
k8s.io/kube-aggregator v0.34.3/go.mod h1:d4D8PV2FK4Qlq6u442FSum1tHPhK9tKdKBfH/A3R0I0=
k8s.io/kube-openapi v0.0.0-20190709113604-33be087ad058/go.mod h1:nfDlWeOsu3pUf4yWGL+ERqohP4YsZcBJXWMK+gkzOA4=
k8s.io/kube-openapi v0.0.0-20190722073852-5e22f3d471e6/go.mod h1:RZvgC8MSN6DjiMV6oIfEE9pDL9CYXokkfaCKZeHm3nc=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
-4
View File
@@ -2198,10 +2198,6 @@ k8s.io/client-go v0.26.2/go.mod h1:u5EjOuSyBa09yqqyY7m3abZeovO/7D/WehVVlZ2qcqU=
k8s.io/client-go v0.33.3/go.mod h1:luqKBQggEf3shbxHY4uVENAxrDISLOarxpTKMiUuujg=
k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY=
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
k8s.io/code-generator v0.34.1 h1:WpphT26E+j7tEgIUfFr5WfbJrktCGzB3JoJH9149xYc=
k8s.io/code-generator v0.34.1/go.mod h1:DeWjekbDnJWRwpw3s0Jat87c+e0TgkxoR4ar608yqvg=
k8s.io/code-generator v0.34.2 h1:9bG6jTxmsU3HXE5BNYJTC8AZ1D6hVVfkm8yYSkdkGY0=
k8s.io/code-generator v0.34.2/go.mod h1:dnDDEd6S/z4uZ+PG1aE58ySCi/lR4+qT3a4DddE4/2I=
k8s.io/code-generator v0.34.3 h1:6ipJKsJZZ9q21BO8I2jEj4OLN3y8/1n4aihKN0xKmQk=
k8s.io/code-generator v0.34.3/go.mod h1:oW73UPYpGLsbRN8Ozkhd6ZzkF8hzFCiYmvEuWZDroI4=
k8s.io/component-base v0.26.2/go.mod h1:DxbuIe9M3IZPRxPIzhch2m1eT7uFrSBJUBuVCQEBivs=
+1 -1
View File
@@ -2,7 +2,7 @@ module github.com/grafana/grafana/hack
go 1.25.5
require k8s.io/code-generator v0.34.2
require k8s.io/code-generator v0.34.3
require (
github.com/go-logr/logr v1.4.2 // indirect
+2 -2
View File
@@ -12,8 +12,8 @@ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
k8s.io/code-generator v0.34.2 h1:9bG6jTxmsU3HXE5BNYJTC8AZ1D6hVVfkm8yYSkdkGY0=
k8s.io/code-generator v0.34.2/go.mod h1:dnDDEd6S/z4uZ+PG1aE58ySCi/lR4+qT3a4DddE4/2I=
k8s.io/code-generator v0.34.3 h1:6ipJKsJZZ9q21BO8I2jEj4OLN3y8/1n4aihKN0xKmQk=
k8s.io/code-generator v0.34.3/go.mod h1:oW73UPYpGLsbRN8Ozkhd6ZzkF8hzFCiYmvEuWZDroI4=
k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f h1:SLb+kxmzfA87x4E4brQzB33VBbT2+x7Zq9ROIHmGn9Q=
k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
+1 -1
View File
@@ -11,7 +11,7 @@ set -o pipefail
SCRIPT_ROOT=$(dirname "${BASH_SOURCE[0]}")/..
pushd "${SCRIPT_ROOT}/hack" && GO111MODULE=on go mod tidy && popd
CODEGEN_PKG=${CODEGEN_PKG:-$(cd "${SCRIPT_ROOT}"; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo $(go env GOPATH)/pkg/mod/k8s.io/code-generator@v0.34.2)}
CODEGEN_PKG=${CODEGEN_PKG:-$(cd "${SCRIPT_ROOT}"; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo $(go env GOPATH)/pkg/mod/k8s.io/code-generator@v0.34.3)}
OUTDIR="${HOME}/go/src"
OPENAPI_VIOLATION_EXCEPTIONS_FILENAME="zz_generated.openapi_violation_exceptions.list"
+1 -1
View File
@@ -92,7 +92,7 @@
"@emotion/eslint-plugin": "11.12.0",
"@grafana/eslint-config": "8.2.0",
"@grafana/eslint-plugin": "link:./packages/grafana-eslint-rules",
"@grafana/plugin-e2e": "^3.0.3",
"@grafana/plugin-e2e": "^3.1.0",
"@grafana/test-utils": "workspace:*",
"@manypkg/get-packages": "^3.0.0",
"@npmcli/package-json": "^6.0.0",
@@ -51,7 +51,7 @@ describe('Resolver', () => {
expect(pages.Alerting.AddAlertRule.url).toBe('/alerting/new');
});
it('should throw error if an invalid semver range is used in versioned selector', () => {
it('should throw an error if an invalid semver range is used in versioned selector', () => {
expect(() =>
resolveSelectors({
Alerting: {
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as React from 'react';
import { type ComponentProps, useRef } from 'react';
import { createDataFrame } from '@grafana/data';
@@ -16,14 +16,14 @@ jest.mock('react-use', () => {
return {
...reactUse,
useMeasure: () => {
const ref = React.useRef();
const ref = useRef(null);
return [ref, { width: 1600 }];
},
};
});
describe('FlameGraph', () => {
function setup(props?: Partial<React.ComponentProps<typeof FlameGraph>>) {
function setup(props?: Partial<ComponentProps<typeof FlameGraph>>) {
const flameGraphData = createDataFrame(data);
const container = new FlameGraphDataContainer(flameGraphData, { collapsing: true });
@@ -21,7 +21,7 @@ jest.mock('@grafana/assistant', () => ({
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useMeasure: () => {
const ref = useRef();
const ref = useRef(null);
return [ref, { width: 1600 }];
},
}));
@@ -262,24 +262,18 @@ function createComponent<Props extends JSX.IntrinsicAttributes>(
pluginId?: string,
id?: string
): ComponentTypeWithExtensionMeta<Props> {
function ComponentWithMeta(props: Props) {
if (Implementation) {
return <Implementation {...props} />;
const ComponentWithMeta: ComponentTypeWithExtensionMeta<Props> = Object.assign(
Implementation || (() => <div>Test</div>),
{
meta: {
id: id ?? '',
pluginId: pluginId ?? '',
title: '',
description: '',
type: PluginExtensionTypes.component,
} satisfies PluginExtensionComponentMeta,
}
return <div>Test</div>;
}
ComponentWithMeta.displayName = '';
ComponentWithMeta.propTypes = {};
ComponentWithMeta.contextTypes = {};
ComponentWithMeta.meta = {
id: id ?? '',
pluginId: pluginId ?? '',
title: '',
description: '',
type: PluginExtensionTypes.component,
} satisfies PluginExtensionComponentMeta;
);
return ComponentWithMeta;
}
@@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { CSSObjectWithLabel } from 'react-select';
import { StylesConfig } from 'react-select';
import { GrafanaTheme2 } from '@grafana/data';
export default function resetSelectStyles(theme: GrafanaTheme2) {
export default function resetSelectStyles(theme: GrafanaTheme2): Partial<StylesConfig> {
return {
clearIndicator: () => ({}),
container: () => ({}),
@@ -13,7 +13,7 @@ export default function resetSelectStyles(theme: GrafanaTheme2) {
groupHeading: () => ({}),
indicatorsContainer: () => ({}),
indicatorSeparator: () => ({}),
input: function (originalStyles: CSSObjectWithLabel) {
input: function (originalStyles) {
return {
...originalStyles,
color: 'inherit',
@@ -27,7 +27,7 @@ export default function resetSelectStyles(theme: GrafanaTheme2) {
loadingIndicator: () => ({}),
loadingMessage: () => ({}),
menu: () => ({}),
menuList: ({ maxHeight }: { maxHeight: number }) => ({
menuList: ({ maxHeight }) => ({
maxHeight,
}),
multiValue: () => ({}),
@@ -38,7 +38,7 @@ export default function resetSelectStyles(theme: GrafanaTheme2) {
multiValueRemove: () => ({}),
noOptionsMessage: () => ({}),
option: () => ({}),
placeholder: (originalStyles: CSSObjectWithLabel) => ({
placeholder: (originalStyles) => ({
...originalStyles,
color: theme.colors.text.secondary,
}),
@@ -47,11 +47,11 @@ export default function resetSelectStyles(theme: GrafanaTheme2) {
};
}
export function useCustomSelectStyles(theme: GrafanaTheme2, width: number | string | undefined) {
export function useCustomSelectStyles(theme: GrafanaTheme2, width: number | string | undefined): Partial<StylesConfig> {
return useMemo(() => {
return {
...resetSelectStyles(theme),
menuPortal: (base: CSSObjectWithLabel) => {
menuPortal: (base) => {
// Would like to correct top position when menu is placed bottom, but have props are not sent to this style function.
// Only state is. https://github.com/JedWatson/react-select/blob/master/packages/react-select/src/components/Menu.tsx#L605
return {
@@ -60,7 +60,7 @@ export function useCustomSelectStyles(theme: GrafanaTheme2, width: number | stri
};
},
//These are required for the menu positioning to function
menu: ({ top, bottom, position }: CSSObjectWithLabel) => {
menu: ({ top, bottom, position }) => {
return {
top,
bottom,
@@ -73,7 +73,7 @@ export function useCustomSelectStyles(theme: GrafanaTheme2, width: number | stri
width: width ? theme.spacing(width) : '100%',
display: width === 'auto' ? 'inline-flex' : 'flex',
}),
option: (provided: CSSObjectWithLabel, state: any) => ({
option: (provided, state) => ({
...provided,
opacity: state.isDisabled ? 0.5 : 1,
}),
@@ -263,7 +263,16 @@ export const Footer: StoryFn<typeof Table> = (args) => {
);
};
export const Pagination: StoryFn<typeof Table> = (args) => <Basic {...args} />;
export const Pagination: StoryFn<typeof Table> = (args) => {
const theme = useTheme2();
const data = buildData(theme, {});
return (
<DashboardStoryCanvas>
<Table {...args} data={data} />
</DashboardStoryCanvas>
);
};
Pagination.args = {
enablePagination: true,
};
+1 -1
View File
@@ -17,7 +17,7 @@ require (
github.com/dave/jennifer v1.7.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/proto v1.14.2 // indirect
github.com/expr-lang/expr v1.17.6 // indirect
github.com/expr-lang/expr v1.17.7 // indirect
github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
+2 -2
View File
@@ -11,8 +11,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/proto v1.14.2 h1:wJPxPy2Xifja9cEMrcA/g08art5+7CGJNFNk35iXC1I=
github.com/emicklei/proto v1.14.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec=
github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
+1 -1
View File
@@ -224,7 +224,7 @@ var (
MStatTotalRepositories prometheus.Gauge
// MUnifiedStorageMigrationStatus indicates the migration status for unified storage in this instance.
// Possible values: 0 (default/undefined), 1 (migration disabled), 2 (migration would run).
// Possible values: 0 (default/undefined), 1 (migration disabled), 2 (migration would run), 3 (migration will run).
MUnifiedStorageMigrationStatus prometheus.Gauge
)
+9 -1
View File
@@ -81,7 +81,15 @@ func (s *SocialGoogle) Validate(ctx context.Context, newSettings ssoModels.SSOSe
return validation.Validate(info, requester,
validation.MustBeEmptyValidator(info.AuthUrl, "Auth URL"),
validation.MustBeEmptyValidator(info.TokenUrl, "Token URL"),
validation.MustBeEmptyValidator(info.ApiUrl, "API URL"))
validation.MustBeEmptyValidator(info.ApiUrl, "API URL"),
loginPromptValidator)
}
func loginPromptValidator(info *social.OAuthInfo, requester identity.Requester) error {
if info.UseRefreshToken && !slices.Contains([]string{"", "consent"}, info.LoginPrompt) {
return ssosettings.ErrInvalidOAuthConfig("If provided, login_prompt must be set to consent when use_refresh_token is enabled.")
}
return nil
}
func (s *SocialGoogle) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
@@ -9,6 +9,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
@@ -18,6 +19,7 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -871,6 +873,39 @@ func TestSocialGoogle_Validate(t *testing.T) {
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if use_refresh_token is enabled and login prompt is neither empty or 'consent'",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"use_refresh_token": "true",
"login_prompt": "login",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "succeeds if use_refresh_token is enabled and login prompt is empty",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"use_refresh_token": "true",
"login_prompt": "",
},
},
wantErr: nil,
},
{
name: "succeeds if use_refresh_token is enabled and login prompt is consent",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"use_refresh_token": "true",
"login_prompt": "consent",
},
},
wantErr: nil,
},
}
for _, tc := range testCases {
@@ -886,7 +921,13 @@ func TestSocialGoogle_Validate(t *testing.T) {
require.ErrorIs(t, err, tc.wantErr)
return
}
require.NoError(t, err)
if err != nil {
var e errutil.Error
require.True(t, errors.As(err, &e))
require.NoError(t, e, "expected no error, got %v", e.PublicMessage)
return
}
})
}
}
@@ -1024,3 +1065,102 @@ func TestIsHDAllowed(t *testing.T) {
})
}
}
func TestSocialGoogle_AuthCodeURL(t *testing.T) {
testCases := []struct {
name string
info *social.OAuthInfo
opts []oauth2.AuthCodeOption
state string
wantURL *url.URL
}{
{
name: "should return the correct auth code URL",
info: &social.OAuthInfo{
ClientId: "client-id",
ClientSecret: "client-secret",
AuthUrl: "https://example.com/auth",
LoginPrompt: "login",
Scopes: []string{"openid", "email", "profile"},
},
state: "test-state",
opts: []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("extra_param", "extra_value"),
},
wantURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/auth",
RawQuery: url.Values{
"state": {"test-state"},
"prompt": {"login"},
"response_type": {"code"},
"client_id": {"client-id"},
"redirect_uri": {"/login/google"},
"scope": {"openid email profile"},
"extra_param": {"extra_value"},
}.Encode(),
},
},
{
name: "should add access type offline and approval force if use refresh token is enabled",
info: &social.OAuthInfo{
ClientId: "client-id",
ClientSecret: "client-secret",
AuthUrl: "https://example.com/auth",
Scopes: []string{"openid", "email", "profile"},
UseRefreshToken: true,
},
state: "test-state",
wantURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/auth",
RawQuery: url.Values{
"state": {"test-state"},
"prompt": {"consent"},
"response_type": {"code"},
"client_id": {"client-id"},
"redirect_uri": {"/login/google"},
"scope": {"openid email profile"},
"access_type": {"offline"},
}.Encode(),
},
},
{
name: "should override configured login prompt if use refresh token is enabled",
info: &social.OAuthInfo{
ClientId: "client-id",
ClientSecret: "client-secret",
AuthUrl: "https://example.com/auth",
Scopes: []string{"openid", "email", "profile"},
UseRefreshToken: true,
},
state: "test-state",
wantURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/auth",
RawQuery: url.Values{
"state": {"test-state"},
"prompt": {"consent"},
"response_type": {"code"},
"client_id": {"client-id"},
"redirect_uri": {"/login/google"},
"scope": {"openid email profile"},
"access_type": {"offline"},
}.Encode(),
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGoogleProvider(tc.info, &setting.Cfg{}, nil, ssosettingstests.NewFakeService(), featuremgmt.WithFeatures())
gotURL := s.AuthCodeURL(tc.state, tc.opts...)
parsedURL, err := url.Parse(gotURL)
require.NoError(t, err)
require.EqualValues(t, tc.wantURL, parsedURL)
})
}
}
+5 -1
View File
@@ -91,7 +91,11 @@ func (s *SocialBase) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) st
func (s *SocialBase) getAuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
if s.info.LoginPrompt != "" {
promptOpt := oauth2.SetAuthURLParam("prompt", s.info.LoginPrompt)
opts = append(opts, promptOpt)
// Prepend the prompt option to the opts slice to ensure it is applied last.
// This is necessary in case the caller provides an option that overrides the prompt,
// such as `oauth2.ApprovalForce`.
opts = append([]oauth2.AuthCodeOption{promptOpt}, opts...)
}
return s.Config.AuthCodeURL(state, opts...)
+1 -1
View File
@@ -17,7 +17,7 @@ require (
github.com/cockroachdb/apd/v3 v3.2.1 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/emicklei/proto v1.14.2 // indirect
github.com/expr-lang/expr v1.17.6 // indirect
github.com/expr-lang/expr v1.17.7 // indirect
github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
+2 -2
View File
@@ -12,8 +12,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/proto v1.14.2 h1:wJPxPy2Xifja9cEMrcA/g08art5+7CGJNFNk35iXC1I=
github.com/emicklei/proto v1.14.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec=
github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
@@ -8,6 +8,7 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
@@ -33,8 +34,11 @@ func (d dashboardStorageWrapper) Update(ctx context.Context, name string, objInf
obj, created, err := d.Storage.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
if err == nil && ns.OrgID > 0 && d.live != nil {
if err := d.live.DashboardSaved(ns.OrgID, name); err != nil {
logging.FromContext(ctx).Info("live dashboard update failed", "err", err)
m, err := utils.MetaAccessor(obj)
if err == nil {
if err := d.live.DashboardSaved(ns.OrgID, name, m.GetResourceVersion()); err != nil {
logging.FromContext(ctx).Info("live dashboard update failed", "err", err)
}
}
}
return obj, created, err
@@ -154,12 +154,6 @@ func (a *dashboardSqlAccess) executeQuery(ctx context.Context, helper *legacysql
return nil
})
// Use transaction from unified storage if available in the context.
// This allows us to run migrations in a transaction which is specifically required for SQLite.
if tx == nil {
tx = resource.TransactionFromContext(ctx)
}
if tx != nil {
return tx.QueryContext(ctx, query, args...)
}
@@ -277,6 +277,16 @@ func (r *DualReadWriter) createOrUpdate(ctx context.Context, create bool, opts D
// FIXME: to make sure if behaves in the same way as in sync, we should
// we should refactor the code to use the same function.
if r.shouldUpdateGrafanaDB(opts, parsed) {
// HACK: Get the has from repository -- this will avoid an additional RV increment
// we should change the signature of Create and Update to return FileInfo instead
info, _ = r.repo.Read(ctx, opts.Path, opts.Ref)
if info != nil {
parsed.Meta.SetSourceProperties(utils.SourceProperties{
Path: opts.Path,
Checksum: info.Hash,
})
}
if _, err := r.folders.EnsureFolderPathExist(ctx, opts.Path); err != nil {
return nil, fmt.Errorf("ensure folder path exists: %w", err)
}
+8 -6
View File
@@ -26,9 +26,10 @@ const (
// DashboardEvent events related to dashboards
type dashboardEvent struct {
UID string `json:"uid"`
Action actionType `json:"action"` // saved, editing, deleted
SessionID string `json:"sessionId,omitempty"`
UID string `json:"uid"`
Action actionType `json:"action"` // saved, editing, deleted
SessionID string `json:"sessionId,omitempty"`
ResourceVersion string `json:"rv,omitempty"`
}
// DashboardHandler manages all the `grafana/dashboard/*` channels
@@ -105,10 +106,11 @@ func (h *DashboardHandler) publish(orgID int64, event dashboardEvent) error {
}
// DashboardSaved will broadcast to all connected dashboards
func (h *DashboardHandler) DashboardSaved(orgID int64, uid string) error {
func (h *DashboardHandler) DashboardSaved(orgID int64, uid string, rv string) error {
return h.publish(orgID, dashboardEvent{
UID: uid,
Action: ActionSaved,
UID: uid,
Action: ActionSaved,
ResourceVersion: rv,
})
}
+1 -1
View File
@@ -482,7 +482,7 @@ type GrafanaLive struct {
// DashboardActivityChannel is a service to advertise dashboard activity
type DashboardActivityChannel interface {
// Called when a dashboard is saved
DashboardSaved(orgID int64, uid string) error
DashboardSaved(orgID int64, uid string, rv string) error
// Called when a dashboard is deleted
DashboardDeleted(orgID int64, uid string) error
+3
View File
@@ -632,6 +632,9 @@ type UnifiedStorageConfig struct {
DataSyncerInterval time.Duration
// DataSyncerRecordsLimit defines how many records will be processed at max during a sync invocation.
DataSyncerRecordsLimit int
// EnableMigration indicates whether migration is enabled for the resource.
// If not set, will use the default from MigratedUnifiedResources.
EnableMigration bool
}
type InstallPlugin struct {
+32 -11
View File
@@ -8,10 +8,17 @@ import (
"github.com/grafana/grafana/pkg/util/osutil"
)
var MigratedUnifiedResources = []string{
"playlists.playlist.grafana.app",
"folders.folder.grafana.app",
"dashboards.dashboard.grafana.app",
const (
PlaylistResource = "playlists.playlist.grafana.app"
FolderResource = "folders.folder.grafana.app"
DashboardResource = "dashboards.dashboard.grafana.app"
)
// MigratedUnifiedResources maps resources to a boolean indicating if migration is enabled by default
var MigratedUnifiedResources = map[string]bool{
PlaylistResource: true, // enabled by default
FolderResource: false,
DashboardResource: false,
}
// read storage configs from ini file. They look like:
@@ -46,12 +53,19 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
// parse dataSyncerInterval from resource section
dataSyncerInterval := section.Key("dataSyncerInterval").MustDuration(time.Hour)
// parse EnableMigration from resource section
enableMigration := MigratedUnifiedResources[resourceName]
if section.HasKey("enableMigration") {
enableMigration = section.Key("enableMigration").MustBool(MigratedUnifiedResources[resourceName])
}
storageConfig[resourceName] = UnifiedStorageConfig{
DualWriterMode: rest.DualWriterMode(dualWriterMode),
DualWriterPeriodicDataSyncJobEnabled: dualWriterPeriodicDataSyncJobEnabled,
DualWriterMigrationDataSyncDisabled: dualWriterMigrationDataSyncDisabled,
DataSyncerRecordsLimit: dataSyncerRecordsLimit,
DataSyncerInterval: dataSyncerInterval,
EnableMigration: enableMigration,
}
}
cfg.UnifiedStorage = storageConfig
@@ -61,8 +75,8 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false)
if !cfg.DisableDataMigrations && cfg.getUnifiedStorageType() == "unified" {
// Helper log to find instances running migrations in the future
cfg.Logger.Info("Unified migration configs not yet enforced")
// cfg.enforceMigrationToUnifiedConfigs() // TODO: uncomment when ready for release
cfg.Logger.Info("Unified migration configs enforced")
cfg.enforceMigrationToUnifiedConfigs()
} else {
// Helper log to find instances disabling migration
cfg.Logger.Info("Unified migration configs enforcement disabled", "storage_type", cfg.getUnifiedStorageType(), "disable_data_migrations", cfg.DisableDataMigrations)
@@ -107,7 +121,6 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("")
}
// nolint:unused
// enforceMigrationToUnifiedConfigs enforces configurations required to run migrated resources in mode 5
// All migrated resources in MigratedUnifiedResources are set to mode 5 and unified search is enabled
func (cfg *Cfg) enforceMigrationToUnifiedConfigs() {
@@ -118,14 +131,22 @@ func (cfg *Cfg) enforceMigrationToUnifiedConfigs() {
section.Key("enable_search").SetValue("true")
cfg.EnableSearch = true
}
for _, resource := range MigratedUnifiedResources {
cfg.Logger.Info("Enforcing mode 5 for resource in unified storage", "resource", resource)
if oldCfg, ok := cfg.UnifiedStorage[resource]; ok {
cfg.Logger.Info("Overriding unified storage config for migrated resource", "resource", resource, "old_config", oldCfg)
for resource, enabledByDefault := range MigratedUnifiedResources {
resourceCfg, ok := cfg.UnifiedStorage[resource]
if ok {
if !resourceCfg.EnableMigration {
cfg.Logger.Info("Resource migration disabled", "resource", resource)
continue
}
cfg.Logger.Info("Overriding unified storage config for migrated resource", "resource", resource, "old_config", resourceCfg)
} else if !enabledByDefault {
continue
}
cfg.Logger.Info("Enforcing mode 5 for resource in unified storage", "resource", resource)
cfg.UnifiedStorage[resource] = UnifiedStorageConfig{
DualWriterMode: 5,
DualWriterMigrationDataSyncDisabled: true,
EnableMigration: true,
}
}
}
+50 -16
View File
@@ -4,6 +4,7 @@ import (
"testing"
"time"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/stretchr/testify/assert"
)
@@ -13,31 +14,56 @@ func TestCfg_setUnifiedStorageConfig(t *testing.T) {
err := cfg.Load(CommandLineArgs{HomePath: "../../", Config: "../../conf/defaults.ini"})
assert.NoError(t, err)
s, err := cfg.Raw.NewSection("unified_storage.playlists.playlist.grafana.app")
assert.NoError(t, err)
setSectionKey := func(sectionName, key, value string) {
section := cfg.Raw.Section(sectionName) // Gets existing or creates new
_, err := section.NewKey(key, value)
assert.NoError(t, err)
}
_, err = s.NewKey("dualWriterMode", "2")
assert.NoError(t, err)
setMigratedResourceKey := func(key, value string) {
for migratedResource := range MigratedUnifiedResources {
setSectionKey("unified_storage."+migratedResource, key, value)
}
}
_, err = s.NewKey("dualWriterPeriodicDataSyncJobEnabled", "true")
assert.NoError(t, err)
validateMigratedResources := func(optIn bool) {
for migratedResource, enabled := range MigratedUnifiedResources {
resourceCfg, exists := cfg.UnifiedStorage[migratedResource]
_, err = s.NewKey("dataSyncerRecordsLimit", "1001")
assert.NoError(t, err)
isEnabled := enabled
if optIn {
isEnabled = true
}
_, err = s.NewKey("dataSyncerInterval", "10m")
assert.NoError(t, err)
if !isEnabled {
if exists {
assert.Equal(t, rest.DualWriterMode(1), resourceCfg.DualWriterMode, migratedResource)
}
continue
}
assert.Equal(t, exists, true, migratedResource)
assert.Equal(t, UnifiedStorageConfig{
DualWriterMode: 5,
DualWriterMigrationDataSyncDisabled: true,
EnableMigration: isEnabled,
}, resourceCfg, migratedResource)
}
}
setMigratedResourceKey("dualWriterMode", "1") // migrated resources enabled by default will change to 5 in setUnifiedStorageConfig
setSectionKey("unified_storage.resource.not_migrated.grafana.app", "dualWriterMode", "2")
setSectionKey("unified_storage.resource.not_migrated.grafana.app", "dualWriterPeriodicDataSyncJobEnabled", "true")
setSectionKey("unified_storage.resource.not_migrated.grafana.app", "dataSyncerRecordsLimit", "1001")
setSectionKey("unified_storage.resource.not_migrated.grafana.app", "dataSyncerInterval", "10m")
// Add unified_storage section for index settings
unifiedStorageSection, err := cfg.Raw.NewSection("unified_storage")
assert.NoError(t, err)
_, err = unifiedStorageSection.NewKey("index_min_count", "5")
assert.NoError(t, err)
setSectionKey("unified_storage", "index_min_count", "5")
cfg.setUnifiedStorageConfig()
value, exists := cfg.UnifiedStorage["playlists.playlist.grafana.app"]
value, exists := cfg.UnifiedStorage["resource.not_migrated.grafana.app"]
assert.Equal(t, exists, true)
assert.Equal(t, value, UnifiedStorageConfig{
@@ -47,6 +73,14 @@ func TestCfg_setUnifiedStorageConfig(t *testing.T) {
DataSyncerInterval: time.Minute * 10,
})
validateMigratedResources(false)
setMigratedResourceKey("enableMigration", "true") // will be changed to 5 in setUnifiedStorageConfig
cfg.setUnifiedStorageConfig()
validateMigratedResources(true)
// Test that index settings are correctly parsed
assert.Equal(t, 5, cfg.IndexMinCount)
})
@@ -2,6 +2,7 @@ package apistore
import (
"context"
"encoding/json"
"math/rand/v2"
"strings"
"testing"
@@ -19,6 +20,7 @@ import (
authlib "github.com/grafana/authlib/types"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
@@ -195,6 +197,42 @@ func TestPrepareObjectForStorage(t *testing.T) {
require.Equal(t, int64(2), meta2.GetGeneration())
})
t.Run("Update should skip incrementing generation when content is unchanged", func(t *testing.T) {
dashboard := dashv1.Dashboard{
ObjectMeta: v1.ObjectMeta{
Name: "test",
Generation: 123,
Annotations: map[string]string{
"A": "B",
utils.AnnoKeyUpdatedTimestamp: "2025-12-17T01:01:00Z",
},
UID: "XXX",
},
Spec: v0alpha1.Unstructured{
Object: map[string]any{
"hello": "world",
},
},
}
dashboard.Name = "test-name"
obj := dashboard.DeepCopyObject()
tmp, err := utils.MetaAccessor(obj)
tmp.SetGeneration(2)
tmp.SetUpdatedTimestampMillis(12345)
require.NoError(t, err)
v, err := s.prepareObjectForUpdate(ctx, obj, &dashboard)
require.NoError(t, err)
require.False(t, v.hasChanged, "no changes")
out := &unstructured.Unstructured{}
err = json.Unmarshal(v.raw.Bytes(), out)
require.NoError(t, err)
require.Equal(t, int64(123), tmp.GetGeneration())
require.Equal(t, "2025-12-17T01:01:00Z", tmp.GetAnnotation(utils.AnnoKeyUpdatedTimestamp))
})
s.opts.RequireDeprecatedInternalID = true
t.Run("Should generate internal id", func(t *testing.T) {
dashboard := dashv1.Dashboard{}
+120 -3
View File
@@ -162,14 +162,49 @@ func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) {
}
})
t.Run("Step 3: verify data is migrated to unified storage", func(t *testing.T) {
// Migrations will run automatically at startup and mode 5 is enforced by the config
t.Run("Step 3: verify that opted-out resources are not migrated", func(t *testing.T) {
// Build unified storage config for Mode5
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
for _, tc := range testCases {
for _, gvr := range tc.resources() {
resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group)
unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{
DualWriterMode: grafanarest.Mode5,
EnableMigration: false,
}
}
}
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
GrafanaOpts: testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
DisableDBCleanup: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: unifiedConfig,
},
Org1Users: org1,
OrgBUsers: orgB,
})
t.Cleanup(helper.Shutdown)
for _, state := range testStates {
t.Run(state.tc.name(), func(t *testing.T) {
// Verify resources don't exist in unified storage yet
state.tc.verify(t, helper, false)
})
}
verifyRegisteredMigrations(t, helper, false, true)
})
t.Run("Step 4: verify data is migrated to unified storage", func(t *testing.T) {
// Migrations enabled by default will run automatically at startup and mode 5 is enforced by the config
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
GrafanaOpts: testinfra.GrafanaOpts{
// EnableLog: true,
AppModeProduction: true,
DisableAnonymous: true,
DisableDataMigrations: false, // Run migrations at startup
DisableDBCleanup: true,
APIServerStorageType: "unified",
},
Org1Users: org1,
@@ -183,7 +218,89 @@ func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) {
state.tc.verify(t, helper, true)
})
}
t.Logf("Verifying migrations are correctly registered")
verifyRegisteredMigrations(t, helper, true, false)
})
t.Run("Step 5: verify data is migrated for all migrations", func(t *testing.T) {
// Trigger migrations that are not enabled by default
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
for _, tc := range testCases {
for _, gvr := range tc.resources() {
resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group)
unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{
EnableMigration: true,
}
}
}
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
GrafanaOpts: testinfra.GrafanaOpts{
// EnableLog: true,
AppModeProduction: true,
DisableAnonymous: true,
DisableDataMigrations: false,
APIServerStorageType: "unified",
UnifiedStorageConfig: unifiedConfig,
},
Org1Users: org1,
OrgBUsers: orgB,
})
t.Cleanup(helper.Shutdown)
for _, state := range testStates {
t.Run(state.tc.name(), func(t *testing.T) {
// Verify resources still exist in unified storage after restart
state.tc.verify(t, helper, true)
})
}
t.Logf("Verifying migrations are correctly registered")
verifyRegisteredMigrations(t, helper, false, false)
})
}
const (
migrationScope = "unifiedstorage"
migrationTable = migrationScope + "_migration_log"
playlistsID = "playlists migration"
foldersAndDashboardsID = "folders and dashboards migration"
)
var migrationIDsToDefault = map[string]bool{
playlistsID: true,
foldersAndDashboardsID: false,
}
func verifyRegisteredMigrations(t *testing.T, helper *apis.K8sTestHelper, onlyDefault bool, optOut bool) {
getMigrationsQuery := fmt.Sprintf("SELECT migration_id FROM %s", migrationTable)
createTableMigrationID := fmt.Sprintf("create %s table", migrationTable)
expectedMigrationIDs := []string{createTableMigrationID}
for id, enabled := range migrationIDsToDefault {
if onlyDefault && !enabled {
continue
}
if optOut {
continue
}
expectedMigrationIDs = append(expectedMigrationIDs, id)
}
rows, err := helper.GetEnv().SQLStore.GetEngine().DB().Query(getMigrationsQuery)
require.NoError(t, err)
defer func() {
require.NoError(t, rows.Close())
}()
migrationIDs := make(map[string]struct{})
for rows.Next() {
var migrationID string
require.NoError(t, rows.Scan(&migrationID))
require.Contains(t, expectedMigrationIDs, migrationID)
migrationIDs[migrationID] = struct{}{}
}
require.NoError(t, rows.Err())
require.Len(t, migrationIDs, len(expectedMigrationIDs))
}
// verifyResourceCount verifies that the expected number of resources exist in K8s storage
+54 -5
View File
@@ -7,7 +7,9 @@ import (
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
playlists "github.com/grafana/grafana/apps/playlist/pkg/apis/playlist/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"k8s.io/apimachinery/pkg/runtime/schema"
)
@@ -17,7 +19,13 @@ type ResourceDefinition struct {
MigratorFunc string // Name of the method: "MigrateFolders", "MigrateDashboards", etc.
}
var registeredResources = []ResourceDefinition{
type migrationDefinition struct {
name string
resources []string
registerFunc func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient)
}
var resourceRegistry = []ResourceDefinition{
{
GroupResource: schema.GroupResource{Group: folders.GROUP, Resource: folders.RESOURCE},
MigratorFunc: "MigrateFolders",
@@ -36,9 +44,50 @@ var registeredResources = []ResourceDefinition{
},
}
var migrationRegistry = []migrationDefinition{
{
name: "playlists",
resources: []string{setting.PlaylistResource},
registerFunc: registerPlaylistMigration,
},
{
name: "folders and dashboards",
resources: []string{setting.FolderResource, setting.DashboardResource},
registerFunc: registerDashboardAndFolderMigration,
},
}
func registerMigrations(cfg *setting.Cfg, mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) error {
for _, migration := range migrationRegistry {
var (
hasValue bool
allEnabled bool
)
for _, res := range migration.resources {
enabled := cfg.UnifiedStorage[res].EnableMigration
if !hasValue {
allEnabled = enabled
hasValue = true
continue
}
if enabled != allEnabled {
return fmt.Errorf("cannot migrate resources separately: %v migration must be either all enabled or all disabled", migration.resources)
}
}
if !allEnabled {
logger.Info("Migration is disabled in config, skipping", "migration", migration.name)
continue
}
migration.registerFunc(mg, migrator, client)
}
return nil
}
func getResourceDefinition(group, resource string) *ResourceDefinition {
for i := range registeredResources {
r := &registeredResources[i]
for i := range resourceRegistry {
r := &resourceRegistry[i]
if r.GroupResource.Group == group && r.GroupResource.Resource == resource {
return r
}
@@ -80,13 +129,13 @@ func getMigratorFunc(accessor legacy.MigrationDashboardAccessor, group, resource
func validateRegisteredResources() error {
registeredMap := make(map[string]bool)
for _, gr := range registeredResources {
for _, gr := range resourceRegistry {
key := fmt.Sprintf("%s.%s", gr.GroupResource.Resource, gr.GroupResource.Group)
registeredMap[key] = true
}
var missing []string
for _, expected := range setting.MigratedUnifiedResources {
for expected := range setting.MigratedUnifiedResources {
if !registeredMap[expected] {
missing = append(missing, expected)
}
@@ -0,0 +1,92 @@
package migrations
import (
"testing"
sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/stretchr/testify/require"
)
// TestRegisterMigrations exercises registerMigrations with various EnableMigration configs using a table-driven test.
func TestRegisterMigrations(t *testing.T) {
origRegistry := migrationRegistry
t.Cleanup(func() { migrationRegistry = origRegistry })
// helper to build a fake registry with custom register funcs that bump counters
makeFakeRegistry := func(migrationCalls map[string]int) []migrationDefinition {
return []migrationDefinition{
{
name: "playlists",
resources: []string{setting.PlaylistResource},
registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) {
migrationCalls["playlists"]++
},
},
{
name: "folders and dashboards",
resources: []string{setting.FolderResource, setting.DashboardResource},
registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) {
migrationCalls["folders and dashboards"]++
},
},
}
}
// Build a minimal cfg with UnifiedStorage entries used by registerMigrations
makeCfg := func(vals map[string]bool) *setting.Cfg {
cfg := &setting.Cfg{UnifiedStorage: make(map[string]setting.UnifiedStorageConfig)}
for k, v := range vals {
cfg.UnifiedStorage[k] = setting.UnifiedStorageConfig{EnableMigration: v}
}
return cfg
}
// Table of scenarios
tests := []struct {
name string
enablePlaylist bool
enableFolder bool
enableDashboard bool
wantPlaylistCalls int
wantFDCalls int
wantErr bool
}{
{name: "playlists enabled", enablePlaylist: true, wantPlaylistCalls: 1},
{name: "playlists disabled", enablePlaylist: false, wantPlaylistCalls: 0},
{name: "folders+dashboards both enabled", enableFolder: true, enableDashboard: true, wantFDCalls: 1},
{name: "folders enabled, dashboards disabled (mismatch)", enableFolder: true, enableDashboard: false, wantFDCalls: 0, wantErr: true},
{name: "folders disabled, dashboards enabled (mismatch)", enableFolder: false, enableDashboard: true, wantFDCalls: 0, wantErr: true},
{name: "folders+dashboards both disabled", enableFolder: false, enableDashboard: false, wantFDCalls: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
migrationCalls := map[string]int{
"playlists": 0,
"folders and dashboards": 0,
}
migrationRegistry = makeFakeRegistry(migrationCalls)
cfg := makeCfg(map[string]bool{
setting.PlaylistResource: tt.enablePlaylist,
setting.FolderResource: tt.enableFolder,
setting.DashboardResource: tt.enableDashboard,
})
// We pass nils for migrator dependencies because our fake registerFuncs don't use them
err := registerMigrations(cfg, nil, nil, nil)
if tt.wantErr {
require.Error(t, err, "expected error for mismatched enablement")
} else {
require.NoError(t, err, "unexpected error")
}
require.Equal(t, tt.wantPlaylistCalls, migrationCalls["playlists"], "playlists register call count")
require.Equal(t, tt.wantFDCalls, migrationCalls["folders and dashboards"], "folders+dashboards register call count")
})
}
}
+15 -18
View File
@@ -3,7 +3,6 @@ package migrations
import (
"context"
"fmt"
"os"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/kvstore"
@@ -49,34 +48,25 @@ func ProvideUnifiedStorageMigrationService(
}
func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error {
// TODO: temporary skip migrations in test environments to prevent integration test timeouts.
if os.Getenv("GRAFANA_TEST_DB") != "" {
return nil
}
// skip migrations if disabled in config
if p.cfg.DisableDataMigrations {
metrics.MUnifiedStorageMigrationStatus.Set(1)
logger.Info("Data migrations are disabled, skipping")
return nil
} else {
metrics.MUnifiedStorageMigrationStatus.Set(2)
logger.Info("Data migrations not yet enforced, skipping")
}
// TODO: Re-enable once migrations are ready
// TODO: add guarantee that this only runs once
// return RegisterMigrations(p.migrator, p.cfg, p.sqlStore, p.client)
return nil
logger.Info("Running migrations for unified storage")
metrics.MUnifiedStorageMigrationStatus.Set(3)
return RegisterMigrations(ctx, p.migrator, p.cfg, p.sqlStore, p.client)
}
func RegisterMigrations(
ctx context.Context,
migrator UnifiedMigrator,
cfg *setting.Cfg,
sqlStore db.DB,
client resource.ResourceClient,
) error {
ctx, span := tracer.Start(context.Background(), "storage.unified.RegisterMigrations")
ctx, span := tracer.Start(ctx, "storage.unified.RegisterMigrations")
defer span.End()
mg := sqlstoremigrator.NewScopedMigrator(sqlStore.GetEngine(), cfg, "unifiedstorage")
mg.AddCreateMigration()
@@ -89,12 +79,19 @@ func RegisterMigrations(
return err
}
// Register resource migrations
registerDashboardAndFolderMigration(mg, migrator, client)
registerPlaylistMigration(mg, migrator, client)
if err := registerMigrations(cfg, mg, migrator, client); err != nil {
return err
}
// Run all registered migrations (blocking)
sec := cfg.Raw.Section("database")
db := mg.DBEngine.DB().DB
maxOpenConns := db.Stats().MaxOpenConnections
if maxOpenConns <= 2 {
// migrations require at least 3 connections due to extra GRPC connections
db.SetMaxOpenConns(3)
defer db.SetMaxOpenConns(maxOpenConns)
}
if err := mg.RunMigrations(ctx,
sec.Key("migration_locking").MustBool(true),
sec.Key("locking_attempt_timeout_sec").MustInt()); err != nil {
@@ -1166,11 +1166,12 @@ func TestIntegrationConvertPrometheusEndpoints_Editor(t *testing.T) {
testinfra.SQLiteIntegrationTest(t)
dir, gpath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
EnableRecordingRules: true,
DisableAuthZClientCache: true,
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
EnableRecordingRules: true,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, gpath)
@@ -1192,10 +1192,11 @@ func TestIntegrationExportFileProvisionContactPoints(t *testing.T) {
func TestIntegrationFullpath(t *testing.T) {
dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
DisableAuthZClientCache: true,
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p)
+15 -12
View File
@@ -53,10 +53,11 @@ func TestIntegrationAlertRulePermissions(t *testing.T) {
testinfra.SQLiteIntegrationTest(t)
dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
DisableAuthZClientCache: true,
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p)
@@ -360,10 +361,11 @@ func TestIntegrationAlertRuleNestedPermissions(t *testing.T) {
testinfra.SQLiteIntegrationTest(t)
dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
DisableAuthZClientCache: true,
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p)
@@ -1429,10 +1431,11 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
testinfra.SQLiteIntegrationTest(t)
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
DisableAuthZClientCache: true,
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
@@ -32,8 +32,9 @@ func TestIntegrationAnnotations(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
EnableFeatureToggles: []string{featuremgmt.FlagAnnotationPermissionUpdate},
DisableAuthZClientCache: true,
DisableAnonymous: true,
EnableFeatureToggles: []string{featuremgmt.FlagAnnotationPermissionUpdate},
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
noneUserID := tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
+64 -31
View File
@@ -12,6 +12,7 @@ import (
"testing"
"time"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -38,8 +39,15 @@ func TestMain(m *testing.M) {
func TestIntegrationDashboardServiceValidation(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
for _, resource := range []string{"folders.folder.grafana.app", "dashboards.dashboard.grafana.app"} {
unifiedConfig[resource] = setting.UnifiedStorageConfig{
EnableMigration: true,
}
}
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
DisableAnonymous: true,
UnifiedStorageConfig: unifiedConfig,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
@@ -218,11 +226,12 @@ func TestIntegrationDashboardServiceValidation(t *testing.T) {
require.NoError(t, err)
})
t.Run("When updating uid with id", func(t *testing.T) {
dashboardWithDuplicatedLegacyAnnotation := "new-uid"
t.Run("When saving a dashboard with an already used legacy ID", func(t *testing.T) {
resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{
"dashboard": map[string]interface{}{
"id": savedDashInFolder.ID, // nolint:staticcheck
"uid": "new-uid",
"uid": dashboardWithDuplicatedLegacyAnnotation,
"title": "Updated title",
},
"folderUid": savedDashInFolder.FolderUID,
@@ -234,7 +243,48 @@ func TestIntegrationDashboardServiceValidation(t *testing.T) {
require.NoError(t, err)
})
t.Run("When updating uid with a dashboard already using that uid", func(t *testing.T) {
t.Run("When updating a dashboard with legacy ID in multiple dashboards", func(t *testing.T) {
resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{
"dashboard": map[string]interface{}{
"id": savedDashInFolder.ID, // nolint:staticcheck
"uid": savedDashInGeneralFolder.UID,
"title": "Updated title",
},
"folderUid": savedDashInFolder.FolderUID,
"overwrite": true,
})
require.NoError(t, err)
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
// Delete the dashboard with duplicated legacy ID annotation
u := fmt.Sprintf("http://admin:admin@%s/api/dashboards/uid/%s", grafanaListedAddr, dashboardWithDuplicatedLegacyAnnotation)
req, err := http.NewRequest("DELETE", u, nil)
require.NoError(t, err)
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
err = resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("When updating a dashboard already using that uid", func(t *testing.T) {
resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{
"dashboard": map[string]interface{}{
"id": savedDashInFolder.ID,
"uid": savedDashInFolder.UID,
"title": "Dashboard with existing UID",
},
"folderUid": savedDashInFolder.FolderUID,
"overwrite": true,
})
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
})
t.Run("When updating id with a dashboard already using that uid", func(t *testing.T) {
resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{
"dashboard": map[string]interface{}{
"id": savedDashInFolder.ID, // nolint:staticcheck
@@ -268,8 +318,9 @@ func TestIntegrationDashboardServiceValidation(t *testing.T) {
require.NoError(t, err)
})
// Obs: in legacy, the dashboard request would fail
// After the dashboard is created, the user can see that there is an error with the library panel and can remove them manually
t.Run("When creating a dashboard that references a non-existent library panel", func(t *testing.T) {
originalCount := getDashboardCount(t, grafanaListedAddr, "admin", "admin")
resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{
"dashboard": map[string]interface{}{
"title": "Bad dashboard",
@@ -285,15 +336,11 @@ func TestIntegrationDashboardServiceValidation(t *testing.T) {
},
})
require.NoError(t, err)
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
_, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), "library element could not be found")
err = resp.Body.Close()
require.NoError(t, err)
// A new dashboard is not created in this situation.
require.Equal(t, originalCount, getDashboardCount(t, grafanaListedAddr, "admin", "admin"))
})
}
@@ -332,7 +379,7 @@ func TestIntegrationDashboardQuota(t *testing.T) {
dashboardDTO := &plugindashboards.PluginDashboard{}
err = json.Unmarshal(b, dashboardDTO)
require.NoError(t, err)
require.EqualValues(t, 1, dashboardDTO.DashboardId)
require.EqualValues(t, "just testing", dashboardDTO.Title)
})
t.Run("when quota limit exceeds importing a dashboard should fail", func(t *testing.T) {
@@ -421,7 +468,7 @@ providers:
dashboardUID = d.UID
dashboardID = d.ID // nolint:staticcheck
}
assert.Equal(t, int64(1), dashboardID)
assert.Len(t, *dashboardList, 1)
testCases := []struct {
desc string
@@ -781,7 +828,7 @@ func TestIntegrationImportDashboardWithLibraryPanels(t *testing.T) {
},
{
"id": 2,
"title": "Library Panel 2",
"title": "Library Panel 2",
"type": "stat",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
"libraryPanel": {
@@ -805,7 +852,7 @@ func TestIntegrationImportDashboardWithLibraryPanels(t *testing.T) {
}
},
"test-lib-panel-2": {
"uid": "test-lib-panel-2",
"uid": "test-lib-panel-2",
"name": "Test Library Panel 2",
"kind": 1,
"type": "stat",
@@ -1011,26 +1058,12 @@ func postDashboard(t *testing.T, grafanaListedAddr, user, password string, paylo
return http.Post(u, "application/json", bytes.NewBuffer(payloadBytes)) // nolint:gosec
}
func getDashboardCount(t *testing.T, grafanaListenAddr, user, password string) int {
endpoint := fmt.Sprintf("http://%s:%s@%s/apis/dashboard.grafana.app/v0alpha1/namespaces/default/search", user, password, grafanaListenAddr)
resp, err := http.Get(endpoint) //nolint:gosec
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
var payload map[string]any
require.NoError(t, json.Unmarshal(body, &payload))
return int(payload["totalHits"].(float64))
}
func TestIntegrationDashboardServicePermissions(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
DisableAnonymous: true,
DisableAuthZClientCache: true,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
+4 -3
View File
@@ -42,9 +42,10 @@ func TestIntegrationStars(t *testing.T) {
}
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false, // required for experimental APIs
DisableAnonymous: true,
EnableFeatureToggles: flags,
DisableDataMigrations: true,
AppModeProduction: false, // required for experimental APIs
DisableAnonymous: true,
EnableFeatureToggles: flags,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: mode,
+9 -5
View File
@@ -37,7 +37,8 @@ func TestMain(m *testing.M) {
func runDashboardTest(t *testing.T, mode rest.DualWriterMode, gvr schema.GroupVersionResource) {
t.Run("simple crud+list", func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
DisableAnonymous: true,
DisableDataMigrations: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: mode,
@@ -194,7 +195,9 @@ func TestIntegrationLegacySupport(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
ctx := context.Background()
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{})
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableDataMigrations: true,
})
clientV0 := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
@@ -500,9 +503,10 @@ func runDashboardSearchTest(t *testing.T, mode rest.DualWriterMode) {
ctx := context.Background()
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
AppModeProduction: true,
DisableDataMigrations: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: mode},
"folders.folder.grafana.app": {DualWriterMode: mode},
@@ -68,7 +68,8 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) {
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
// Create a K8sTestHelper which will set up a real API server
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
DisableDataMigrations: true,
DisableAnonymous: true,
EnableFeatureToggles: []string{
featuremgmt.FlagKubernetesDashboards, // Enable FE-only dashboard feature flag
},
@@ -105,7 +106,8 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) {
t.Run(fmt.Sprintf("DualWriterMode %d - kubernetesDashboards disabled", dualWriterMode), func(t *testing.T) {
// Create a K8sTestHelper which will set up a real API server
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
DisableDataMigrations: true,
DisableAnonymous: true,
DisableFeatureToggles: []string{
featuremgmt.FlagKubernetesDashboards,
},
@@ -138,7 +140,8 @@ func TestIntegrationDashboardAPIAuthorization(t *testing.T) {
for _, dualWriterMode := range dualWriterModes {
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
DisableDataMigrations: true,
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: dualWriterMode,
@@ -185,7 +188,8 @@ func TestIntegrationDashboardAPI(t *testing.T) {
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
// Create a K8sTestHelper which will set up a real API server
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
DisableDataMigrations: true,
DisableAnonymous: true,
EnableFeatureToggles: []string{
featuremgmt.FlagKubernetesDashboards,
},
@@ -29,7 +29,8 @@ func TestIntegrationLibraryPanelConnections(t *testing.T) {
for _, dualWriterMode := range dualWriterModes {
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
DisableDataMigrations: true,
DisableAnonymous: true,
EnableFeatureToggles: []string{
"kubernetesLibraryPanels",
},
@@ -93,7 +94,8 @@ func TestIntegrationLibraryElementPermissions(t *testing.T) {
for _, dualWriterMode := range dualWriterModes {
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
DisableDataMigrations: true,
DisableAnonymous: true,
EnableFeatureToggles: []string{
"kubernetesLibraryPanels",
"grafanaAPIServerWithExperimentalAPIs", // needed until we move it to v0beta1 at least (currently v0alpha1)
@@ -296,7 +298,8 @@ func TestIntegrationLibraryPanelConnectionsWithFolderAccess(t *testing.T) {
for _, dualWriterMode := range dualWriterModes {
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
DisableDataMigrations: true,
DisableAnonymous: true,
EnableFeatureToggles: []string{
"kubernetesLibraryPanels",
},
+4 -3
View File
@@ -37,9 +37,10 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) {
ctx := context.Background()
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: mode},
"folders.folder.grafana.app": {DualWriterMode: mode},
+4 -3
View File
@@ -48,9 +48,10 @@ func TestIntegrationFolderTree(t *testing.T) {
for _, mode := range modes {
t.Run(fmt.Sprintf("mode %d", mode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: mode,
+70 -51
View File
@@ -139,9 +139,10 @@ func TestIntegrationFoldersApp(t *testing.T) {
t.Run(fmt.Sprintf("with dual write (unified storage, mode %v)", modeDw), func(t *testing.T) {
doFolderTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -153,9 +154,10 @@ func TestIntegrationFoldersApp(t *testing.T) {
t.Run(fmt.Sprintf("with dual write (unified storage, mode %v, create nested folders)", modeDw), func(t *testing.T) {
doNestedCreateTest(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -166,9 +168,10 @@ func TestIntegrationFoldersApp(t *testing.T) {
t.Run(fmt.Sprintf("with dual write (unified storage, mode %v, create existing folder)", modeDw), func(t *testing.T) {
doCreateDuplicateFolderTest(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -179,9 +182,10 @@ func TestIntegrationFoldersApp(t *testing.T) {
t.Run(fmt.Sprintf("when creating a folder, mode %v, it should trim leading and trailing spaces", modeDw), func(t *testing.T) {
doCreateEnsureTitleIsTrimmedTest(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -192,9 +196,10 @@ func TestIntegrationFoldersApp(t *testing.T) {
t.Run(fmt.Sprintf("with dual write (unified storage, mode %v, create circular reference folder)", modeDw), func(t *testing.T) {
doCreateCircularReferenceFolderTest(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -218,9 +223,10 @@ func TestIntegrationFoldersApp(t *testing.T) {
for _, mode := range modes {
t.Run(fmt.Sprintf("mode %d", mode), func(t *testing.T) {
doListFoldersTest(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: mode,
@@ -735,9 +741,10 @@ func TestIntegrationFolderCreatePermissions(t *testing.T) {
t.Run(fmt.Sprintf("Mode_%d", mode), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -862,9 +869,10 @@ func TestIntegrationFolderGetPermissions(t *testing.T) {
t.Run(fmt.Sprintf("Mode_%d", mode), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -1079,9 +1087,10 @@ func TestIntegrationFoldersCreateAPIEndpointK8S(t *testing.T) {
for mode := 0; mode <= 4; mode++ {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -1254,9 +1263,10 @@ func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -1366,9 +1376,10 @@ func TestIntegrationFolderDeletionBlockedByLibraryElements(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -1446,9 +1457,10 @@ func TestIntegrationRootFolderDeletionBlockedByLibraryElementsInSubfolder(t *tes
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -1542,9 +1554,10 @@ func TestIntegrationFolderDeletionBlockedByConnectedLibraryPanels(t *testing.T)
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -1620,9 +1633,10 @@ func TestIntegrationFolderDeletionWithDanglingLibraryPanels(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -1901,9 +1915,10 @@ func TestIntegrationDeleteNestedFoldersPostorder(t *testing.T) {
t.Run(fmt.Sprintf("Mode %d: Delete nested folder hierarchy in postorder", mode), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -2027,9 +2042,10 @@ func TestIntegrationDeleteFolderWithProvisionedDashboards(t *testing.T) {
t.Run(fmt.Sprintf("Mode %d: Delete provisioned folders and dashboards", mode), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
ops := testinfra.GrafanaOpts{
DisableAnonymous: true,
AppModeProduction: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
DisableAnonymous: true,
AppModeProduction: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: modeDw,
@@ -2131,11 +2147,14 @@ func TestIntegrationDeleteFolderWithProvisionedDashboards(t *testing.T) {
// Test that folders created during provisioning using the dual writer have the
// appropriate labels and annotations in unified storage.
func TestIntegrationProvisionedFolderPropagatesLabelsAndAnnotations(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
mode3 := grafanarest.DualWriterMode(3)
ops := testinfra.GrafanaOpts{
DisableAnonymous: true,
AppModeProduction: true,
APIServerStorageType: "unified",
DisableDataMigrations: true,
DisableAnonymous: true,
AppModeProduction: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: mode3,
@@ -40,7 +40,7 @@
"tags": [
"Playlist"
],
"description": "list objects of kind Playlist",
"description": "list or watch objects of kind Playlist",
"operationId": "listPlaylist",
"parameters": [
{
@@ -1850,6 +1850,32 @@
"description": "Time is a wrapper around time.Time which supports correct marshaling to YAML and JSON. Wrappers are provided for many of the factory methods that the time package offers.",
"type": "string",
"format": "date-time"
},
"io.k8s.apimachinery.pkg.apis.meta.v1.WatchEvent": {
"description": "Event represents a single event to a watched resource.",
"type": "object",
"required": [
"type",
"object"
],
"properties": {
"object": {
"description": "Object is:\n * If Type is Added or Modified: the new state of the object.\n * If Type is Deleted: the state of the object immediately before deletion.\n * If Type is Error: *Status is recommended; other types may make sense\n depending on context.",
"allOf": [
{
"$ref": "#/components/schemas/io.k8s.apimachinery.pkg.runtime.RawExtension"
}
]
},
"type": {
"type": "string",
"default": ""
}
}
},
"io.k8s.apimachinery.pkg.runtime.RawExtension": {
"description": "RawExtension is used to hold extensions in external versions.\n\nTo use this, make a field which has RawExtension as its type in your external, versioned struct, and Object in your internal struct. You also need to register your various plugin types.\n\n// Internal package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.Object `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// External package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.RawExtension `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// On the wire, the JSON will look something like this:\n\n\t{\n\t\t\"kind\":\"MyAPIObject\",\n\t\t\"apiVersion\":\"v1\",\n\t\t\"myPlugin\": {\n\t\t\t\"kind\":\"PluginA\",\n\t\t\t\"aOption\":\"foo\",\n\t\t},\n\t}\n\nSo what happens? Decode first uses json or yaml to unmarshal the serialized data into your external MyAPIObject. That causes the raw JSON to be stored, but not unpacked. The next step is to copy (using pkg/conversion) into the internal struct. The runtime package's DefaultScheme has conversion functions installed which will unpack the JSON stored in RawExtension, turning it into the correct object type, and storing it in the Object. (TODO: In the case where the object is of an unknown type, a runtime.Unknown object will be created and stored.)",
"type": "object"
}
}
}
+103 -87
View File
@@ -54,47 +54,48 @@ func TestIntegrationPlaylist(t *testing.T) {
require.NoError(t, err)
// t.Logf("%s", disco)
require.JSONEq(t, `[
{
"version": "v0alpha1",
"freshness": "Current",
"resources": [
{
"resource": "playlists",
"responseKind": {
"group": "",
"kind": "Playlist",
"version": ""
},
"scope": "Namespaced",
"singularResource": "playlist",
"subresources": [
{
"responseKind": {
"group": "",
"kind": "Playlist",
"version": ""
},
"subresource": "status",
"verbs": [
"get",
"patch",
"update"
]
}
],
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update"
]
}
]
}
]`, disco)
{
"freshness": "Current",
"resources": [
{
"resource": "playlists",
"responseKind": {
"group": "",
"kind": "Playlist",
"version": ""
},
"scope": "Namespaced",
"singularResource": "playlist",
"subresources": [
{
"responseKind": {
"group": "",
"kind": "Playlist",
"version": ""
},
"subresource": "status",
"verbs": [
"get",
"patch",
"update"
]
}
],
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
}
],
"version": "v0alpha1"
}
]`, disco)
})
t.Run("with k8s api flag", func(t *testing.T) {
@@ -106,9 +107,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Run("with dual write (file, mode 0)", func(t *testing.T) {
doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "file", // write the files to disk
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "file", // write the files to disk
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode0,
@@ -119,9 +121,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Run("with dual write (file, mode 1)", func(t *testing.T) {
doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "file", // write the files to disk
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "file", // write the files to disk
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode1,
@@ -132,9 +135,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Run("with dual write (file, mode 2)", func(t *testing.T) {
doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "file", // write the files to disk
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "file", // write the files to disk
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode2,
@@ -145,9 +149,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Run("with dual write (file, mode 3)", func(t *testing.T) {
doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "file", // write the files to disk
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "file", // write the files to disk
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode3,
@@ -158,9 +163,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Run("with dual write (file, mode 5)", func(t *testing.T) {
helper := doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "file", // write the files to disk
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "file", // write the files to disk
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode5,
@@ -198,9 +204,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Run("with dual write (unified storage, mode 0)", func(t *testing.T) {
doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false, // required for unified storage
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeUnified, // use the entity api tables
DisableDataMigrations: true,
AppModeProduction: false, // required for unified storage
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeUnified, // use the entity api tables
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode0,
@@ -211,18 +218,20 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Run("with dual write (unified storage, mode 1)", func(t *testing.T) {
doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false,
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeUnified,
EnableFeatureToggles: []string{},
DisableDataMigrations: true,
AppModeProduction: false,
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeUnified,
EnableFeatureToggles: []string{},
}))
})
t.Run("with dual write (unified storage, mode 2)", func(t *testing.T) {
doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false, // required for unified storage
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeUnified, // use the entity api tables
DisableDataMigrations: true,
AppModeProduction: false, // required for unified storage
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeUnified, // use the entity api tables
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode2,
@@ -233,9 +242,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Run("with dual write (unified storage, mode 3)", func(t *testing.T) {
doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false, // required for unified storage
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeUnified, // use the entity api tables
DisableDataMigrations: true,
AppModeProduction: false, // required for unified storage
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeUnified, // use the entity api tables
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode3,
@@ -246,9 +256,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Run("with dual write (unified storage, mode 5)", func(t *testing.T) {
doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false, // required for unified storage
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeUnified, // use the entity api tables
DisableDataMigrations: true,
AppModeProduction: false, // required for unified storage
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeUnified, // use the entity api tables
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode5,
@@ -262,9 +273,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Skip("local etcd testing")
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeEtcd, // requires etcd running on localhost:2379
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeEtcd, // requires etcd running on localhost:2379
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode0,
@@ -288,9 +300,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Skip("local etcd testing")
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeEtcd, // requires etcd running on localhost:2379
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeEtcd, // requires etcd running on localhost:2379
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode1,
@@ -314,9 +327,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Skip("local etcd testing")
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeEtcd, // requires etcd running on localhost:2379
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeEtcd, // requires etcd running on localhost:2379
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode2,
@@ -340,9 +354,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Skip("local etcd testing")
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeEtcd, // requires etcd running on localhost:2379
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeEtcd, // requires etcd running on localhost:2379
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode3,
@@ -366,9 +381,10 @@ func TestIntegrationPlaylist(t *testing.T) {
t.Skip("local etcd testing")
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeEtcd, // requires etcd running on localhost:2379
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: options.StorageTypeEtcd, // requires etcd running on localhost:2379
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode5,
+10
View File
@@ -362,6 +362,13 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
_, err = rbacSect.NewKey("permission_cache", "false")
require.NoError(t, err)
if opts.DisableAuthZClientCache {
authzSect, err := cfg.NewSection("authorization")
require.NoError(t, err)
_, err = authzSect.NewKey("cache_ttl", "0")
require.NoError(t, err)
}
analyticsSect, err := cfg.NewSection("analytics")
require.NoError(t, err)
_, err = analyticsSect.NewKey("intercom_secret", "intercom_secret_at_config")
@@ -547,6 +554,8 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
require.NoError(t, err)
_, err = section.NewKey("dualWriterMode", fmt.Sprintf("%d", v.DualWriterMode))
require.NoError(t, err)
_, err = section.NewKey("enableMigration", fmt.Sprintf("%t", v.EnableMigration))
require.NoError(t, err)
}
}
if opts.UnifiedStorageEnableSearch {
@@ -670,6 +679,7 @@ type GrafanaOpts struct {
DisableDataMigrations bool
SecretsManagerEnableDBMigrations bool
OpenFeatureAPIEnabled bool
DisableAuthZClientCache bool
// Allow creating grafana dir beforehand
Dir string
@@ -119,11 +119,11 @@ export const LoginCtrl = memo(({ resetCode, children }: Props) => {
}, []);
const login = useCallback(
(formModel: FormModel) => {
async (formModel: FormModel) => {
setLoginErrorMessage(undefined);
setIsLoggingIn(true);
getBackendSrv()
return getBackendSrv()
.post<LoginDTO>('/login', formModel, { showErrorAlert: false })
.then((result) => {
setResult(result);
@@ -316,9 +316,7 @@ exports[`Query and expressions reducer should set data queries 1`] = `
"type": "and",
},
"query": {
"params": [
"A",
],
"params": [],
},
"reducer": {
"params": [],
@@ -9,6 +9,8 @@ import {
import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { mockDataQuery, mockReduceExpression, mockThresholdExpression } from '../../../mocks';
import {
QueriesAndExpressionsState,
addNewDataQuery,
@@ -453,4 +455,73 @@ describe('Query and expressions reducer', () => {
);
expect(newState).toMatchSnapshot();
});
describe('dangling reference handling', () => {
it('should clear expression reference when removing a data query that is referenced by a reduce expression', () => {
const dataQuery = mockDataQuery({ refId: 'A' });
const reduceExpr = mockReduceExpression({ refId: 'B', expression: 'A' });
const initialState: QueriesAndExpressionsState = {
queries: [dataQuery, reduceExpr],
};
// Remove the data query A
const newState = queriesAndExpressionsReducer(initialState, removeExpression('A'));
// The reduce expression should still exist but its reference should be cleared
expect(newState.queries).toHaveLength(1);
expect(newState.queries[0].refId).toBe('B');
expect(newState.queries[0].model.expression).toBeUndefined();
});
it('should clear expression reference when removing a data query via setDataQueries', () => {
const dataQuery = mockDataQuery({ refId: 'A' });
const mathExpr: AlertQuery<ExpressionQuery> = {
refId: 'C',
queryType: 'expression',
datasourceUid: ExpressionDatasourceUID,
model: {
refId: 'C',
type: ExpressionQueryType.math,
expression: '$A + 10', // references data query A
datasource: {
type: '__expr__',
uid: '__expr__',
},
},
};
const initialState: QueriesAndExpressionsState = {
queries: [dataQuery, mathExpr],
};
// Remove all data queries (simulating user deleting query A)
const newState = queriesAndExpressionsReducer(initialState, setDataQueries([]));
// The math expression should still exist but reference to A should be cleared
expect(newState.queries).toHaveLength(1);
expect(newState.queries[0].refId).toBe('C');
// Math expressions with dangling refs should have them removed from the expression string
expect(newState.queries[0].model.expression).not.toContain('$A');
});
it('should clear expression reference when removing an expression that is referenced by another expression', () => {
const dataQuery = mockDataQuery({ refId: 'A' });
const reduceExpr = mockReduceExpression({ refId: 'B', expression: 'A' });
const thresholdExpr = mockThresholdExpression({ refId: 'C', expression: 'B' });
const initialState: QueriesAndExpressionsState = {
queries: [dataQuery, reduceExpr, thresholdExpr],
};
// Remove expression B which is referenced by C
const newState = queriesAndExpressionsReducer(initialState, removeExpression('B'));
// Both A and C should remain, but C's reference to B should be cleared
expect(newState.queries).toHaveLength(2);
expect(newState.queries.map((q) => q.refId)).toEqual(['A', 'C']);
const thresholdQuery = newState.queries.find((q) => q.refId === 'C');
expect(thresholdQuery?.model.expression).toBeUndefined();
});
});
});
@@ -23,7 +23,7 @@ import { logError } from '../../../Analytics';
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
import { getDefaultQueries, getInstantFromDataQuery } from '../../../utils/rule-form';
import { createDagFromQueries, getOriginOfRefId } from '../dag';
import { queriesWithUpdatedReferences, refIdExists } from '../util';
import { queriesWithRemovedReferences, queriesWithUpdatedReferences, refIdExists } from '../util';
// this one will be used as the refID when we create a new reducer for the threshold expression
export const NEW_REDUCER_REF = 'reducer';
@@ -101,7 +101,17 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
});
})
.addCase(setDataQueries, (state, { payload }) => {
const expressionQueries = state.queries.filter((query) => isExpressionQuery(query.model));
const previousDataQueries = state.queries.filter((query) => !isExpressionQuery(query.model));
const removedRefIds = previousDataQueries
.filter((q) => !payload.some((p) => p.refId === q.refId))
.map((q) => q.refId);
let expressionQueries = state.queries.filter((query) => isExpressionQuery(query.model));
for (const removedRefId of removedRefIds) {
expressionQueries = queriesWithRemovedReferences(expressionQueries, removedRefId);
}
state.queries = [...payload, ...expressionQueries];
})
.addCase(setRecordingRulesQueries, (state, { payload }) => {
@@ -153,7 +163,8 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
});
})
.addCase(removeExpression, (state, { payload }) => {
state.queries = state.queries.filter((query) => query.refId !== payload);
const filteredQueries = state.queries.filter((query) => query.refId !== payload);
state.queries = queriesWithRemovedReferences(filteredQueries, payload);
})
.addCase(removeExpressions, (state) => {
state.queries = state.queries.filter((query) => !isExpressionQuery(query.model));
@@ -7,7 +7,9 @@ import {
containsPathSeparator,
findRenamedDataQueryReferences,
getThresholdsForQueries,
queriesWithRemovedReferences,
queriesWithUpdatedReferences,
removeMathExpressionRef,
updateMathExpressionRefs,
} from './util';
@@ -228,6 +230,80 @@ describe('rule-editor', () => {
expect(updateMathExpressionRefs('$A3 + $B', 'A', 'C')).toBe('$A3 + $B');
});
});
describe('queriesWithRemovedReferences', () => {
it('should clear reference in reduce expression when data query is removed', () => {
const queries: AlertQuery[] = [dataSource, reduceExpression];
const updatedQueries = queriesWithRemovedReferences(queries, 'A');
expect(updatedQueries[0]).toEqual(dataSource);
expect(updatedQueries[1].model.expression).toBeUndefined();
});
it('should clear reference in threshold expression when expression is removed', () => {
const queries: AlertQuery[] = [dataSource, reduceExpression, thresholdExpression];
const updatedQueries = queriesWithRemovedReferences(queries, 'B');
expect(updatedQueries[0]).toEqual(dataSource);
expect(updatedQueries[1]).toEqual(reduceExpression);
expect(updatedQueries[2].model.expression).toBeUndefined();
});
it('should remove reference from math expression', () => {
const queries: AlertQuery[] = [dataSource, mathExpression];
const updatedQueries = queriesWithRemovedReferences(queries, 'A');
const mathModel = updatedQueries[1].model as ExpressionQuery;
expect(mathModel.expression).not.toContain('$A');
expect(mathModel.expression).not.toContain('${A}');
});
it('should remove refId from classic condition params', () => {
const queries: AlertQuery[] = [dataSource, classicCondition];
const updatedQueries = queriesWithRemovedReferences(queries, 'A');
const classicModel = updatedQueries[1].model as ExpressionQuery;
expect(classicModel.conditions?.[0].query.params).toEqual([]);
});
it('should not modify queries that do not reference the removed refId', () => {
const dataSource2 = { ...dataSource, refId: 'B' };
const reduceB = { ...reduceExpression, refId: 'C', model: { ...reduceExpression.model, expression: 'B' } };
const queries: AlertQuery[] = [dataSource, dataSource2, reduceB];
const updatedQueries = queriesWithRemovedReferences(queries, 'A');
expect(updatedQueries[0]).toEqual(dataSource);
expect(updatedQueries[1]).toEqual(dataSource2);
expect(updatedQueries[2]).toEqual(reduceB);
});
it('should handle resample expressions', () => {
const queries: AlertQuery[] = [dataSource, resampleExpression];
const updatedQueries = queriesWithRemovedReferences(queries, 'A');
expect(updatedQueries[1].model.expression).toBeUndefined();
});
});
describe('removeMathExpressionRef', () => {
it('should remove $A pattern', () => {
expect(removeMathExpressionRef('$A + 10', 'A')).toBe('+ 10');
});
it('should remove ${A} pattern', () => {
expect(removeMathExpressionRef('${A} + 10', 'A')).toBe('+ 10');
});
it('should remove multiple references', () => {
const result = removeMathExpressionRef('$A + $A * 2', 'A');
expect(result.replace(/\s+/g, ' ').trim()).toBe('+ * 2');
});
it('should not remove partial matches', () => {
expect(removeMathExpressionRef('$ABC + 10', 'A')).toBe('$ABC + 10');
});
});
});
describe('containsPathSeparator', () => {
@@ -75,6 +75,70 @@ export function queriesWithUpdatedReferences(
});
}
export function queriesWithRemovedReferences(queries: AlertQuery[], removedRefId: string): AlertQuery[] {
return queries.map((query) => {
if (!isExpressionQuery(query.model)) {
return query;
}
const isMathExpression = query.model.type === 'math';
const isReduceExpression = query.model.type === 'reduce';
const isResampleExpression = query.model.type === 'resample';
const isClassicExpression = query.model.type === 'classic_conditions';
const isThresholdExpression = query.model.type === 'threshold';
const isSqlExpression = query.model.type === 'sql';
if (isMathExpression) {
const updatedExpression = removeMathExpressionRef(query.model.expression ?? '', removedRefId);
return {
...query,
model: {
...query.model,
expression: updatedExpression || undefined,
},
};
}
if (isResampleExpression || isReduceExpression || isThresholdExpression) {
const isReferencing = query.model.expression === removedRefId;
return {
...query,
model: {
...query.model,
// Set to undefined to clear the dangling reference
expression: isReferencing ? undefined : query.model.expression,
},
};
}
if (isSqlExpression) {
// SQL expressions reference table names, not query refIds in the same way
// For now, we'll leave SQL expressions unchanged as they work differently
return query;
}
if (isClassicExpression) {
const conditions = query.model.conditions?.map((condition) => ({
...condition,
query: {
...condition.query,
params: condition.query.params.filter((param: string) => param !== removedRefId),
},
}));
return { ...query, model: { ...query.model, conditions } };
}
return query;
});
}
export function removeMathExpressionRef(expression: string, refIdToRemove: string): string {
// Remove both $refId and ${refId} patterns
const refPattern = new RegExp('(\\$' + refIdToRemove + '\\b)|(\\${' + refIdToRemove + '})', 'gm');
return expression.replace(refPattern, '').trim();
}
export function updateMathExpressionRefs(expression: string, previousRefId: string, newRefId: string): string {
const oldExpression = new RegExp('(\\$' + previousRefId + '\\b)|(\\${' + previousRefId + '})', 'gm');
const newExpression = '${' + newRefId + '}';
@@ -165,10 +165,10 @@ describe('CloneRuleEditor', function () {
);
await waitFor(() => {
expect(ui.inputs.name.get()).toHaveValue('First Ruler Rule (copy)');
expect(ui.inputs.namespace.get()).toHaveTextContent('namespace-one');
});
expect(ui.inputs.name.get()).toHaveValue('First Ruler Rule (copy)');
expect(ui.inputs.expr.get()).toHaveValue('vector(1) > 0');
expect(ui.inputs.namespace.get()).toHaveTextContent('namespace-one');
expect(ui.inputs.group.get()).toHaveTextContent('group1');
expect(
byRole('listitem', {
@@ -35,17 +35,23 @@ export const FieldRenderer = ({
const [isSecretConfigured, setIsSecretConfigured] = useState(secretConfigured);
const isDependantField = typeof field !== 'string';
const name = isDependantField ? field.name : field;
const parentValue = isDependantField ? watch(field.dependsOn) : null;
const parentValue = isDependantField && field.dependsOn ? watch(field.dependsOn) : null;
const fieldData = fieldMap(provider)[name];
const theme = useTheme2();
// Handle disabledWhen configuration
const disabledWhen = isDependantField ? field.disabledWhen : undefined;
const disabledWhenValue = disabledWhen ? watch(disabledWhen.field) : undefined;
const isDisabled = disabledWhen ? disabledWhenValue === disabledWhen.is : false;
// Unregister a field that depends on a toggle to clear its data
useEffect(() => {
if (isDependantField) {
if (isDependantField && field.dependsOn) {
if (!parentValue) {
unregister(name);
}
}
}, [unregister, name, parentValue, isDependantField]);
}, [unregister, name, parentValue, isDependantField, field]);
const isNotEmptySelectableValueArray = (
current: string | boolean | Record<string, string> | Array<SelectableValue<string>> | undefined
@@ -64,6 +70,13 @@ export const FieldRenderer = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Set the value when the field is disabled
useEffect(() => {
if (isDisabled && disabledWhen?.disabledValue) {
setValue(name, disabledWhen.disabledValue.value);
}
}, [isDisabled, disabledWhen?.disabledValue, name, setValue]);
if (!field) {
console.log('missing field:', name);
return null;
@@ -74,12 +87,12 @@ export const FieldRenderer = ({
}
// Dependant field means the field depends on another field's value and shouldn't be rendered if the parent field is false
if (isDependantField) {
const parentValue = watch(field.dependsOn);
if (isDependantField && field.dependsOn) {
if (!parentValue) {
return null;
}
}
const fieldProps = {
label: fieldData.label,
required: !!fieldData.validation?.required,
@@ -131,10 +144,10 @@ export const FieldRenderer = ({
rules={fieldData.validation}
name={name}
control={control}
render={({ field: { ref, onChange, ...fieldProps }, fieldState: { invalid } }) => {
render={({ field: { ref, onChange, ...controllerFieldProps }, fieldState: { invalid } }) => {
return (
<Select
{...fieldProps}
{...controllerFieldProps}
placeholder={fieldData.placeholder}
isMulti={fieldData.multi}
invalid={invalid}
@@ -143,6 +156,7 @@ export const FieldRenderer = ({
allowCustomValue={!!fieldData.allowCustomValue}
defaultValue={fieldData.defaultValue}
onChange={onChange}
disabled={isDisabled}
onCreateOption={(v) => {
const customValue = { value: v, label: v };
onChange([...(options || []), customValue]);
+28 -9
View File
@@ -142,7 +142,14 @@ export const getSectionFields = (): Section => {
'allowSignUp',
'autoLogin',
'signoutRedirectUrl',
'loginPrompt',
{
name: 'loginPrompt',
disabledWhen: {
field: 'useRefreshToken',
is: true,
disabledValue: { value: 'consent', label: t('auth-config.fields.login-prompt-consent', 'Consent') },
},
},
],
},
{
@@ -729,10 +736,16 @@ export function fieldMap(provider: string): Record<string, FieldData> {
},
useRefreshToken: {
label: t('auth-config.fields.use-refresh-token-label', 'Use refresh token'),
description: t(
'auth-config.fields.use-refresh-token-description',
'If enabled, Grafana will fetch a new access token using the refresh token provided by the OAuth2 provider.'
),
description:
provider === 'google'
? t(
'auth-config.fields.use-refresh-token-description-google',
'If enabled, Grafana will fetch a new access token using the refresh token provided by Google. This forces the login prompt to "Consent" to ensure Google returns a refresh token.'
)
: t(
'auth-config.fields.use-refresh-token-description',
'If enabled, Grafana will fetch a new access token using the refresh token provided by the OAuth2 provider.'
),
type: 'checkbox',
},
tlsClientCa: {
@@ -922,10 +935,16 @@ export function fieldMap(provider: string): Record<string, FieldData> {
loginPrompt: {
label: t('auth-config.fields.login-prompt-label', 'Login prompt'),
type: 'select',
description: t(
'auth-config.fields.login-prompt-description',
'Indicates the type of user interaction when the user logs in with the IdP.'
),
description:
provider === 'google'
? t(
'auth-config.fields.login-prompt-description-google',
'Indicates the type of user interaction when the user logs in with Google. This is forced to "Consent" when "Use refresh token" is enabled.'
)
: t(
'auth-config.fields.login-prompt-description',
'Indicates the type of user interaction when the user logs in with the IdP.'
),
multi: false,
options: [
{ value: '', label: '' },
+16 -1
View File
@@ -134,9 +134,24 @@ export type FieldData = {
content?: (setValue: UseFormSetValue<SSOProviderDTO>) => ReactElement;
};
/** Configuration for conditionally disabling a field based on another field's value */
export type DisabledWhenConfig = {
/** The field name to watch */
field: keyof SSOProviderDTO;
/** The value that triggers the disabled state */
is: boolean | string;
/** The value to set when disabled */
disabledValue?: SelectableValue<string>;
};
export type SSOSettingsField =
| keyof SSOProvider['settings']
| { name: keyof SSOProvider['settings']; dependsOn: keyof SSOProvider['settings']; hidden?: boolean };
| {
name: keyof SSOProvider['settings'];
dependsOn?: keyof SSOProvider['settings'];
disabledWhen?: DisabledWhenConfig;
hidden?: boolean;
};
export interface ServerDiscoveryFormData {
url: string;
@@ -9,6 +9,7 @@ import { ElementSelectionContext, useSidebar, useStyles2, Sidebar } from '@grafa
import NativeScrollbar, { DivScrollElement } from 'app/core/components/NativeScrollbar';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { KioskMode } from 'app/types/dashboard';
import { DashboardScene } from '../scene/DashboardScene';
@@ -32,7 +33,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
const styles = useStyles2(getStyles, headerHeight ?? 0);
const { chrome } = useGrafana();
const { kioskMode } = chrome.useState();
const isInKioskMode = kioskMode === KioskMode.Full;
const { isPlaying } = playlistSrv.useState();
if (!config.featureToggles.dashboardNewLayouts) {
return (
@@ -94,8 +95,10 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
};
function renderBody() {
const renderWithoutSidebar = isPlaying || kioskMode === KioskMode.Full;
// In kiosk mode the full document body scrolls so we don't need to wrap in our own scrollbar
if (isInKioskMode) {
if (renderWithoutSidebar) {
return (
<div
className={cx(styles.bodyWrapper, styles.bodyWrapperKiosk)}
@@ -15,13 +15,14 @@ export function DashboardDataLayerControls({ dashboard }: { dashboard: Dashboard
const state = sceneGraph.getData(dashboard).useState();
// It is possible to render the controls for the annotation data layers in separate places using the `placement` property.
// In case it's not specified, we are rendering the controls here (default).
const isDefaultPlacement = (layer: SceneDataLayerProvider) => layer.state.placement === undefined;
const isDefaultPlacementAndNotHidden = (layer: SceneDataLayerProvider) =>
layer.state.placement === undefined && !layer.state.isHidden;
const styles = useStyles2(getStyles);
if (isDashboardDataLayerSetState(state)) {
return (
<>
{state.annotationLayers.filter(isDefaultPlacement).map((layer) => (
{state.annotationLayers.filter(isDefaultPlacementAndNotHidden).map((layer) => (
<div key={layer.state.key} className={styles.container}>
<DataLayerControl layer={layer} />
</div>
@@ -35,6 +36,7 @@ export function DashboardDataLayerControls({ dashboard }: { dashboard: Dashboard
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
label: 'dashboard-data-layer-controls',
display: 'inline-flex',
alignItems: 'center',
verticalAlign: 'middle',
@@ -18,7 +18,7 @@ export function DashboardLinksControls({ links, dashboard }: Props) {
const uid = dashboard.state.uid;
const styles = useStyles2(getStyles);
if (!links || !uid) {
if (!links || !uid || links.length === 0) {
return null;
}
@@ -36,6 +36,7 @@ export function DashboardLinksControls({ links, dashboard }: Props) {
function getStyles(theme: GrafanaTheme2) {
return {
linksContainer: css({
label: 'dashboard-links-controls',
display: 'inline-flex',
gap: theme.spacing(1),
marginRight: theme.spacing(1),
@@ -47,5 +47,7 @@ export function DashboardControlsButton({ dashboard }: { dashboard: DashboardSce
const getStyles = (theme: GrafanaTheme2) => ({
dropdownButton: css({
display: 'inline-flex',
marginBottom: theme.spacing(1),
marginRight: theme.spacing(1),
}),
});
@@ -14,6 +14,7 @@ interface PopoverMenuProps {
y: number;
onClickFilterString?: (value: string, refId?: string) => void;
onClickFilterOutString?: (value: string, refId?: string) => void;
onClickSearchString?: (text: string) => void;
onDisable: () => void;
row: LogRowModel;
close: () => void;
@@ -24,6 +25,7 @@ export const PopoverMenu = ({
y,
onClickFilterString,
onClickFilterOutString,
onClickSearchString,
selection,
row,
close,
@@ -50,7 +52,7 @@ export const PopoverMenu = ({
props.onDisable();
}, [props, row.datasourceType, selection.length]);
const supported = onClickFilterString || onClickFilterOutString;
const supported = onClickFilterString || onClickFilterOutString || onClickSearchString;
if (!supported) {
return null;
@@ -89,6 +91,17 @@ export const PopoverMenu = ({
/>
)}
<Menu.Divider />
{onClickSearchString && (
<Menu.Item
label={t('logs.popover-menu.search-text', 'Search in results')}
onClick={() => {
onClickSearchString(selection);
close();
track('search_text', selection.length, row.datasourceType);
}}
/>
)}
<Menu.Divider />
<Menu.Item label={t('logs.popover-menu.disable-menu', 'Disable menu')} onClick={onDisable} />
</Menu>
</div>
@@ -50,7 +50,7 @@ describe('Explore: handle running/not running query', () => {
jest.mocked(datasources.loki.query).mockReturnValueOnce(makeLogsQueryResponse());
// Make sure we render the logs panel
await screen.findByText(/^Logs$/);
await screen.findByRole('heading', { name: /^Logs$/ });
// Make sure we render the log line
await screen.findByText(/custom log line/i);
@@ -122,7 +122,7 @@ describe('Handles open/close splits and related events in UI and URL', () => {
// Make sure we render the logs panel
await waitFor(() => {
const logsPanels = screen.getAllByText(/^Logs$/);
const logsPanels = screen.getAllByRole('heading', { name: /^Logs$/ });
expect(logsPanels.length).toBe(2);
});
@@ -1,37 +1,39 @@
import * as React from 'react';
import { CoreApp, SelectableValue } from '@grafana/data';
import { CoreApp } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Alert, InlineField, InlineFieldRow, Input, Select, TextLink } from '@grafana/ui';
import { Alert, Combobox, ComboboxOption, InlineField, InlineFieldRow, Input, TextLink } from '@grafana/ui';
import { ExpressionQuery, ExpressionQuerySettings, ReducerMode, reducerModes, reducerTypes } from '../types';
interface Props {
app?: CoreApp;
labelWidth?: number | 'auto';
refIds: Array<SelectableValue<string>>;
refIds: Array<ComboboxOption<string>>;
query: ExpressionQuery;
onChange: (query: ExpressionQuery) => void;
}
export const Reduce = ({ labelWidth = 'auto', onChange, app, refIds, query }: Props) => {
const reducer = reducerTypes.find((o) => o.value === query.reducer);
const onRefIdChange = (value: SelectableValue<string>) => {
onChange({ ...query, expression: value.value });
const onRefIdChange = (option: ComboboxOption<string> | null) => {
onChange({ ...query, expression: option?.value });
};
const onSelectReducer = (value: SelectableValue<string>) => {
onChange({ ...query, reducer: value.value });
const onSelectReducer = (option: ComboboxOption<string> | null) => {
onChange({ ...query, reducer: option?.value });
};
const onSettingsChanged = (settings: ExpressionQuerySettings) => {
onChange({ ...query, settings: settings });
};
const onModeChanged = (value: SelectableValue<ReducerMode>) => {
const onModeChanged = (option: ComboboxOption<ReducerMode> | null) => {
if (!option || option.value === null || option.value === undefined) {
return;
}
let newSettings: ExpressionQuerySettings;
switch (value.value) {
switch (option.value) {
case ReducerMode.Strict:
newSettings = { mode: ReducerMode.Strict };
break;
@@ -49,7 +51,7 @@ export const Reduce = ({ labelWidth = 'auto', onChange, app, refIds, query }: Pr
default:
newSettings = {
mode: value.value,
mode: option.value,
};
}
onSettingsChanged(newSettings);
@@ -101,15 +103,17 @@ export const Reduce = ({ labelWidth = 'auto', onChange, app, refIds, query }: Pr
{strictModeNotification()}
<InlineFieldRow>
<InlineField label={t('expressions.reduce.label-input', 'Input')} labelWidth={labelWidth}>
<Select onChange={onRefIdChange} options={refIds} value={query.expression} width={'auto'} />
<Combobox onChange={onRefIdChange} options={refIds} value={query.expression} width={50} />
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label={t('expressions.reduce.label-function', 'Function')} labelWidth={labelWidth}>
<Select options={reducerTypes} value={reducer} onChange={onSelectReducer} width={20} />
<Combobox options={reducerTypes} value={query.reducer} onChange={onSelectReducer} width={50} />
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label={t('expressions.reduce.label-mode', 'Mode')} labelWidth={labelWidth}>
<Select onChange={onModeChanged} options={reducerModes} value={mode} width={25} />
<Combobox onChange={onModeChanged} options={reducerModes} value={mode} width={50} />
</InlineField>
{replaceWithNumber()}
</InlineFieldRow>
+3 -2
View File
@@ -1,4 +1,5 @@
import { DataQuery, ReducerID, SelectableValue } from '@grafana/data';
import { ComboboxOption } from '@grafana/ui';
import { config } from 'app/core/config';
import { EvalFunction } from '../alerting/state/alertDef';
@@ -75,7 +76,7 @@ export const expressionTypes: Array<SelectableValue<ExpressionQueryType>> = [
return true;
});
export const reducerTypes: Array<SelectableValue<string>> = [
export const reducerTypes: Array<ComboboxOption<string>> = [
{ value: ReducerID.min, label: 'Min', description: 'Get the minimum value' },
{ value: ReducerID.max, label: 'Max', description: 'Get the maximum value' },
{ value: ReducerID.mean, label: 'Mean', description: 'Get the average value' },
@@ -91,7 +92,7 @@ export enum ReducerMode {
DropNonNumbers = 'dropNN',
}
export const reducerModes: Array<SelectableValue<ReducerMode>> = [
export const reducerModes: Array<ComboboxOption<ReducerMode>> = [
{
value: ReducerMode.Strict,
label: 'Strict',
@@ -25,9 +25,11 @@ import { DashboardEvent, DashboardEventAction } from './types';
const sessionId = uuidv4();
class DashboardWatcher {
private static readonly IGNORE_SAVE_WINDOW_MS = 5000;
channel?: LiveChannelAddress; // path to the channel
uid?: string;
ignoreSave?: boolean;
ignoreSave = 0; // save any events until this time passes
editing = false;
lastEditing?: DashboardEvent;
subscription?: Unsubscribable;
@@ -84,8 +86,9 @@ class DashboardWatcher {
this.uid = undefined;
}
// ignore the next 5 seconds of save events
ignoreNextSave() {
this.ignoreSave = true;
this.ignoreSave = Date.now() + DashboardWatcher.IGNORE_SAVE_WINDOW_MS;
}
getRecentEditingEvent() {
@@ -115,8 +118,11 @@ class DashboardWatcher {
case DashboardEventAction.EditingStarted:
case DashboardEventAction.Saved: {
if (this.ignoreSave) {
this.ignoreSave = false;
return;
if (this.ignoreSave < Date.now()) {
this.ignoreSave = 0; // process the event
} else {
return;
}
}
const dash = getDashboardSrv().getCurrent();
@@ -11,4 +11,5 @@ export interface DashboardEvent {
message?: string;
sessionId?: string;
timestamp?: number;
rv?: string;
}
@@ -185,11 +185,12 @@ export const LogRowMenuCell = memo(
}
);
type AddonOnClickListener = (event: MouseEvent, row: LogRowModel) => void | undefined;
type AddonOnClickListener = (event: MouseEvent<HTMLElement>, row: LogRowModel) => void | undefined;
type ChildElementProps = Record<string, unknown> & { onClick: AddonOnClickListener };
function addClickListenersToNode(nodes: ReactNode[], row: LogRowModel) {
return nodes.map((node, index) => {
if (isValidElement(node)) {
const onClick: AddonOnClickListener = node.props.onClick;
if (isValidElement<ChildElementProps>(node)) {
const onClick = node.props.onClick;
if (!onClick) {
return node;
}
@@ -9,7 +9,7 @@ import { LogListModel, NEWLINES_REGEX } from '../panel/processing';
export const OTEL_PROBE_FIELD = 'severity_number';
const OTEL_LANGUAGE_UNKNOWN = 'unknown';
function identifyOTelLanguages(logs: LogListModel[] | LogRowModel[]): string[] {
export function identifyOTelLanguages(logs: LogListModel[] | LogRowModel[]): string[] {
const languagesSet = new Set<string>();
logs.forEach((log) => {
const lang = identifyOTelLanguage(log);
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { camelCase, groupBy } from 'lodash';
import { memo, startTransition, useCallback, useMemo, useRef, useState } from 'react';
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DataFrameType, GrafanaTheme2, store, TimeRange } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
@@ -18,6 +18,7 @@ import { LogLineDetailsLinks } from './LogLineDetailsLinks';
import { LogLineDetailsLog } from './LogLineDetailsLog';
import { LogLineDetailsTrace } from './LogLineDetailsTrace';
import { useLogListContext } from './LogListContext';
import { reportInteractionOnce } from './analytics';
import { getTempoTraceFromLinks } from './links';
import { LogListModel } from './processing';
@@ -124,6 +125,21 @@ export const LogLineDetailsComponent = memo(
[fieldsWithLinks.links, fieldsWithLinks.linksFromVariableMap]
);
useEffect(() => {
if (noInteractions) {
return;
}
reportInteractionOnce('logs_log_line_details_fields_displayed', {
links: allLinks.length,
trace: trace !== undefined,
fields: fieldsWithoutLinks.length,
labels: labelsWithLinks.length,
labelGroups: labelGroups.join(', '),
});
// Once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<LogLineDetailsHeader focusLogLine={focusLogLine} log={log} search={search} onSearch={handleSearch} />
@@ -102,7 +102,11 @@ export const LogLineDetailsHeader = ({ focusLogLine, log, search, onSearch }: Pr
}
setDetailsMode(newMode);
}, [detailsMode, logOptionsStorageKey, setDetailsMode]);
reportInteractionWrapper('logs_log_line_details_header_toggle_details_mode', {
newMode,
});
}, [detailsMode, logOptionsStorageKey, reportInteractionWrapper, setDetailsMode]);
const toggleLogLine = useCallback(() => {
if (logLineDisplayed) {
@@ -280,6 +280,7 @@ const LogListComponent = ({
wrapLogMessage,
} = useLogListContext();
const { detailsMode, showDetails, toggleDetails } = useLogDetailsContext();
const { setSearch, showSearch } = useLogListSearchContext();
const [processedLogs, setProcessedLogs] = useState<LogListModel[]>([]);
const [listHeight, setListHeight] = useState(getListHeight(containerElement, app));
const theme = useTheme2();
@@ -441,6 +442,14 @@ const LogListComponent = ({
[debouncedScrollToItem, filteredLogs]
);
const onClickSearchString = useCallback(
(search: string) => {
showSearch();
setSearch(search);
},
[setSearch, showSearch]
);
const logLevels = useMemo(() => getLevelsFromLogs(processedLogs), [processedLogs]);
if (!containerElement || listHeight == null) {
@@ -471,6 +480,7 @@ const LogListComponent = ({
{...popoverState.popoverMenuCoordinates}
onClickFilterString={onClickFilterString}
onClickFilterOutString={onClickFilterOutString}
onClickSearchString={onClickSearchString}
onDisable={onDisablePopoverMenu}
/>
)}
@@ -34,7 +34,7 @@ import { getDefaultDetailsMode, getDetailsWidth } from './LogDetailsContext';
import { LogLineTimestampResolution } from './LogLine';
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
import { LogListOptions, LogListFontSize } from './LogList';
import { reportInteractionOnce } from './analytics';
import { collectInsights } from './analytics';
import { LogListModel } from './processing';
export interface LogListContextData extends Omit<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> {
@@ -241,7 +241,7 @@ export const LogListContextProvider = ({
if (noInteractions) {
return;
}
reportInteractionOnce(`logs_log_list_${app}_logs_displayed`, {
collectInsights(logs, app, {
dedupStrategy,
fontSize,
forceEscape: logListState.forceEscape,
@@ -18,19 +18,11 @@ interface Props {
export const LOG_LIST_SEARCH_HEIGHT = 48;
export const LogListSearch = ({ listRef, logs }: Props) => {
const {
hideSearch,
filterLogs,
matchingUids,
setMatchingUids,
setSearch: setContextSearch,
searchVisible,
toggleFilterLogs,
} = useLogListSearchContext();
const { hideSearch, filterLogs, matchingUids, search, setMatchingUids, setSearch, searchVisible, toggleFilterLogs } =
useLogListSearchContext();
const { displayedFields, noInteractions } = useLogListContext();
const [search, setSearch] = useState('');
const [currentResult, setCurrentResult] = useState<number | null>(null);
const inputRef = useRef('');
const inputRef = useRef<HTMLInputElement>(null);
const searchUsedRef = useRef(false);
const styles = useStyles2(getStyles);
@@ -43,16 +35,15 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
inputRef.current = e.target.value;
startTransition(() => {
setSearch(inputRef.current);
setSearch(inputRef.current?.value ?? '');
});
if (!searchUsedRef.current && !noInteractions) {
reportInteraction('logs_log_list_search_used');
searchUsedRef.current = true;
}
},
[noInteractions]
[noInteractions, setSearch]
);
const prevResult = useCallback(() => {
@@ -78,19 +69,27 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
setCurrentResult(null);
return;
}
if (!currentResult) {
if (currentResult === null) {
setCurrentResult(0);
listRef?.scrollToItem(logs.indexOf(matches[0]), 'center');
// No need to filter if we're only showing matching logs, otherwise scroll to the first result.
if (!filterLogs) {
listRef?.scrollToItem(logs.indexOf(matches[0]), 'center');
}
}
}, [currentResult, listRef, logs, matches]);
}, [currentResult, filterLogs, listRef, logs, matches]);
useEffect(() => {
if (!searchVisible) {
setSearch('');
setContextSearch(undefined);
setMatchingUids(null);
}
}, [searchVisible, setContextSearch, setMatchingUids]);
}, [searchVisible, setMatchingUids]);
useEffect(() => {
if (!inputRef.current || !search) {
return;
}
inputRef.current.value = search;
}, [search]);
useEffect(() => {
const newMatchingUids = matches.map((log) => log.uid);
@@ -104,13 +103,12 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
.forEach((log) => log.setCurrentSearch(undefined));
}
setContextSearch(search ? search : undefined);
if (!sameLogs) {
setMatchingUids(newMatchingUids.length ? newMatchingUids : null);
} else if (!matches.length) {
setMatchingUids(null);
}
}, [logs, matches, matchingUids, search, setContextSearch, setMatchingUids]);
}, [logs, matches, matchingUids, search, setMatchingUids]);
if (!searchVisible) {
return null;
@@ -126,6 +124,7 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
onChange={handleChange}
autoFocus
placeholder={t('logs.log-list-search.input-placeholder', 'Search in logs')}
ref={inputRef}
suffix={suffix}
/>
</div>
@@ -141,22 +140,22 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
onClick={prevResult}
disabled={!matches || !matches.length}
name="angle-up"
aria-label={t('logs.log-list-search.prev', 'Previous result')}
tooltip={t('logs.log-list-search.prev', 'Previous result')}
/>
<IconButton
onClick={nextResult}
disabled={!matches || !matches.length}
name="angle-down"
aria-label={t('logs.log-list-search.next', 'Next result')}
tooltip={t('logs.log-list-search.next', 'Next result')}
/>
<IconButton
onClick={toggleFilterLogs}
disabled={!matches || !matches.length}
className={filterLogs ? styles.controlButtonActive : undefined}
name="filter"
aria-label={t('logs.log-list-search.filter', 'Filter matching logs')}
tooltip={t('logs.log-list-search.filter', 'Filter matching logs')}
/>
<IconButton onClick={hideSearch} name="times" aria-label={t('logs.log-list-search.close', 'Close search')} />
<IconButton onClick={hideSearch} name="times" tooltip={t('logs.log-list-search.close', 'Close search')} />
</div>
);
};
@@ -7,7 +7,7 @@ export interface LogListSearchContextData {
search?: string;
searchVisible?: boolean;
setMatchingUids: (matches: string[] | null) => void;
setSearch: (search: string | undefined) => void;
setSearch: (search: string) => void;
showSearch: () => void;
toggleFilterLogs: () => void;
}
@@ -33,13 +33,14 @@ export const useLogListSearchContext = (): LogListSearchContextData => {
};
export const LogListSearchContextProvider = ({ children }: { children: ReactNode }) => {
const [search, setSearch] = useState<string | undefined>(undefined);
const [search, setSearch] = useState<string>('');
const [searchVisible, setSearchVisible] = useState(false);
const [matchingUids, setMatchingUids] = useState<string[] | null>(null);
const [filterLogs, setFilterLogs] = useState(false);
const hideSearch = useCallback(() => {
setSearchVisible(false);
setSearch('');
}, []);
const showSearch = useCallback(() => {
@@ -1,5 +1,8 @@
import { CoreApp, LogRowModel } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { identifyOTelLanguages } from '../otel/formats';
export const reportInteractionOnce = (interactionName: string, properties?: Record<string, unknown>) => {
const key = `logs.interactions.${interactionName}`;
if (sessionStorage.getItem(key)) {
@@ -8,3 +11,56 @@ export const reportInteractionOnce = (interactionName: string, properties?: Reco
sessionStorage.setItem(key, '1');
reportInteraction(interactionName, properties);
};
export function collectInsights(logs: LogRowModel[], app: CoreApp, properties?: Record<string, unknown>) {
if (!logs.length) {
return;
}
const { longest, shortest, average, median } = getLogsStats(logs);
reportInteractionOnce(`logs_log_list_${app}_logs_displayed`, {
...properties,
otelLanguage: identifyOTelLanguages(logs).join(', '),
ansi: logs.some((logs) => logs.hasAnsi),
unescaped: logs.some((logs) => logs.hasUnescapedContent),
dsType: logs[0]?.datasourceType ?? '',
count: logs.length,
longestLog: longest,
shortestLog: shortest,
averageLog: average,
medianLog: median,
});
}
function getLogsStats(logs: LogRowModel[]) {
let longest = 0,
shortest = logs[0].raw.length,
median = 0;
const lengths: number[] = [];
let sum = 0;
for (let i = 0; i < logs.length; i++) {
let length = logs[i].raw.length;
if (length > longest) {
longest = length;
} else if (length < shortest) {
shortest = length;
}
sum += length;
lengths.push(length);
}
lengths.sort((a, b) => a - b);
const mid = Math.floor(lengths.length / 2);
if (lengths.length % 2 === 0) {
median = (lengths[mid - 1] + lengths[mid]) / 2;
} else {
median = lengths[mid];
}
return { longest, shortest, average: Math.round(sum / logs.length), median };
}
@@ -336,7 +336,7 @@ function countNewLines(log: string, limit = Infinity) {
let count = 0;
for (let i = 0; i < log.length; ++i) {
// No need to iterate further
if (count > Infinity) {
if (count > limit) {
return count;
}
if (log[i] === '\n') {
@@ -15,7 +15,7 @@ export const useKeyBindings = () => {
const { showDetails, detailsMode, closeDetails } = useLogDetailsContext();
useEffect(() => {
function handleToggleSearch(event: KeyboardEvent) {
function handleOpenSearch(event: KeyboardEvent) {
const isMac = navigator.userAgent.includes('Mac');
const isFKey = event.key === 'f' || event.key === 'F';
@@ -23,6 +23,8 @@ export const useKeyBindings = () => {
showSearch();
return;
}
}
function handleClose(event: KeyboardEvent) {
if (event.key === 'Escape' && searchVisible) {
hideSearch();
}
@@ -30,9 +32,11 @@ export const useKeyBindings = () => {
closeDetails();
}
}
document.addEventListener('keydown', handleToggleSearch);
document.addEventListener('keydown', handleOpenSearch);
document.addEventListener('keyup', handleClose);
return () => {
document.removeEventListener('keydown', handleToggleSearch);
document.removeEventListener('keydown', handleOpenSearch);
document.removeEventListener('keyup', handleClose);
};
}, [closeDetails, detailsMode, hideSearch, searchVisible, showDetails.length, showSearch]);
};
@@ -12,6 +12,7 @@ import kbn from 'app/core/utils/kbn';
import { Resource } from 'app/features/apiserver/types';
import { SaveDashboardFormCommonOptions } from 'app/features/dashboard-scene/saving/SaveDashboardForm';
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
@@ -204,6 +205,9 @@ export function SaveProvisionedDashboardForm({
repositoryType: repository?.type ?? 'unknown',
});
// ignore incoming save events
dashboardWatcher.ignoreNextSave();
createOrUpdateFile({
// Skip adding ref to the default branch request
ref: ref === repository?.branch ? undefined : ref,
@@ -79,7 +79,7 @@ export class GeomapPanel extends Component<Props, State> {
this.subs.add(
this.props.eventBus.subscribe(PanelEditExitedEvent, (evt) => {
if (this.mapDiv && this.props.id === evt.payload) {
this.initMapRef(this.mapDiv);
this.initMapAsync(this.mapDiv);
}
})
);
@@ -97,7 +97,7 @@ export class GeomapPanel extends Component<Props, State> {
});
if (hasDependencies) {
this.initMapRef(this.mapDiv);
this.initMapAsync(this.mapDiv);
}
}
})
@@ -182,7 +182,7 @@ export class GeomapPanel extends Component<Props, State> {
if (noRepeatChanged) {
if (this.mapDiv) {
this.initMapRef(this.mapDiv);
this.initMapAsync(this.mapDiv);
}
// Skip other options processing
return;
@@ -227,7 +227,7 @@ export class GeomapPanel extends Component<Props, State> {
this.setState({ legends: this.getLegends() });
}
initMapRef = async (div: HTMLDivElement) => {
initMapAsync = async (div: HTMLDivElement | null) => {
if (!div) {
// Do not initialize new map or dispose old map
return;
@@ -437,6 +437,10 @@ export class GeomapPanel extends Component<Props, State> {
return legends;
}
initMapRef = (div: HTMLDivElement | null) => {
this.initMapAsync(div);
};
render() {
let { ttip, ttipOpen, topRight1, legends, topRight2 } = this.state;
const { options } = this.props;
+4 -1
View File
@@ -3329,6 +3329,7 @@
"login-attribute-path-label": "Login attribute path",
"login-prompt-consent": "Consent",
"login-prompt-description": "Indicates the type of user interaction when the user logs in with the IdP.",
"login-prompt-description-google": "Indicates the type of user interaction when the user logs in with Google. This is forced to \"Consent\" when \"Use refresh token\" is enabled.",
"login-prompt-label": "Login prompt",
"login-prompt-login": "Login",
"login-prompt-select-account": "Select account",
@@ -3386,6 +3387,7 @@
"use-pkce-description": "If enabled, Grafana will use <2>Proof Key for Code Exchange (PKCE)</2> with the OAuth2 Authorization Code Grant.",
"use-pkce-label": "Use PKCE",
"use-refresh-token-description": "If enabled, Grafana will fetch a new access token using the refresh token provided by the OAuth2 provider.",
"use-refresh-token-description-google": "If enabled, Grafana will fetch a new access token using the refresh token provided by Google. This forces the login prompt to \"Consent\" to ensure Google returns a refresh token.",
"use-refresh-token-label": "Use refresh token",
"validate-hosted-domain-description": "If enabled, Grafana will match the Hosted Domain retrieved from the Google ID Token against the \"{{ allowedDomainsLabel }}\" list specified by the user.",
"validate-hosted-domain-label": "Validate hosted domain",
@@ -10181,7 +10183,8 @@
"copy": "Copy selection",
"disable-menu": "Disable menu",
"line-contains": "Add as line contains filter",
"line-contains-not": "Add as line does not contain filter"
"line-contains-not": "Add as line does not contain filter",
"search-text": "Search in results"
},
"show-log-attributes": "Display log attributes for OTel logs",
"timestamp-format": "Timestamp resolution",
+6 -6
View File
@@ -3446,17 +3446,17 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/plugin-e2e@npm:^3.0.3":
version: 3.0.3
resolution: "@grafana/plugin-e2e@npm:3.0.3"
"@grafana/plugin-e2e@npm:^3.1.0":
version: 3.1.0
resolution: "@grafana/plugin-e2e@npm:3.1.0"
dependencies:
"@grafana/e2e-selectors": "npm:^12.4.0-19890644192"
"@grafana/e2e-selectors": "npm:12.4.0-20165274911"
semver: "npm:^7.5.4"
uuid: "npm:^13.0.0"
yaml: "npm:^2.3.4"
peerDependencies:
"@playwright/test": ^1.52.0
checksum: 10/d9247d52cc5b65c91983212790610b0c2470d705fa4e37178d04cbf681c4fc80bb08b2c39ea6f0061cb0d78242f108a5c8e6fa1ad2f2209d6f15d34000cb1d00
checksum: 10/a4003a1c594e8ecd771a8ab7af77357cfe2d942dee821d922e317da2eb7962f86c19bddcc4bdf4d0c793b3ebfec409095b25022b0e3664d66b93163e61cc3fb5
languageName: node
linkType: hard
@@ -19480,7 +19480,7 @@ __metadata:
"@grafana/llm": "npm:1.0.1"
"@grafana/monaco-logql": "npm:^0.0.8"
"@grafana/o11y-ds-frontend": "workspace:*"
"@grafana/plugin-e2e": "npm:^3.0.3"
"@grafana/plugin-e2e": "npm:^3.1.0"
"@grafana/plugin-ui": "npm:^0.11.1"
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"