diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 475380bc006..5a99e5a9841 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -705,7 +705,6 @@ embed.go @grafana/grafana-as-code /.github/pr-checks.json @tolzhabayev /.github/pr-commands.json @tolzhabayev /.github/renovate.json5 @grafana/frontend-ops -/.github/teams.yml @armandgrillet /.github/workflows/alerting-swagger-gen.yml @grafana/alerting-backend /.github/workflows/auto-milestone.yml @grafana/grafana-developer-enablement-squad /.github/workflows/backport.yml @grafana/grafana-developer-enablement-squad @@ -722,7 +721,6 @@ embed.go @grafana/grafana-as-code /.github/workflows/deploy-pr-preview.yml @grafana/docs-tooling /.github/workflows/epic-add-to-platform-ux-parent-project.yml @meanmina /.github/workflows/github-release.yml @grafana/grafana-developer-enablement-squad -/.github/workflows/issue-labeled.yml @armandgrillet /.github/workflows/issue-opened.yml @grafana/grafana-community-support /.github/workflows/metrics-collector.yml @torkelo /.github/workflows/milestone.yml @tolzhabayev diff --git a/.github/teams.yml b/.github/teams.yml deleted file mode 100644 index 94b55fc28df..00000000000 --- a/.github/teams.yml +++ /dev/null @@ -1,104 +0,0 @@ -# The first keys are labels used on issues. All fields are optional. -# Example -test: - # channel-label is used to send a message to a specific channel - # when the label "test" is added to an issue. - channel-label: CXXXXXXXXXX - -# Alerting team -area/alerting: - channel-label: C028MCV4R7C - exclude-github-team: alerting-squad - -# DataViz squad -area/dataviz: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/legend: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/barchart: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/bargauge: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/candlestick: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/canvas: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/common: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/gauge: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/geomap: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/graph: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/heatmap: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/histogram: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/piechart: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/stat: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/state-timeline: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/status-history: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/timeseries: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/trend: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/panel/xychart: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/tooltip: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/transformations: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/value-mapping: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad - -area/units: - channel-label: C04J73AAQ87 # grafana-dataviz - exclude-github-team: dataviz-squad diff --git a/.github/workflows/issue-labeled.yml b/.github/workflows/issue-labeled.yml deleted file mode 100644 index 09513a7d5cc..00000000000 --- a/.github/workflows/issue-labeled.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Notify Slack channel based on new issue label - -on: - issues: - types: [labeled] - -jobs: - config: - runs-on: "ubuntu-latest" - outputs: - has-secrets: ${{ steps.check.outputs.has-secrets }} - steps: - - name: "Check for secrets" - id: check - shell: bash - run: | - if [ -n "${{ (secrets.SLACK_WEBHOOK_URL != '') || '' }}" ]; then - echo "has-secrets=1" >> "$GITHUB_OUTPUT" - fi - - notify: - needs: config - if: needs.config.outputs.has-secrets - runs-on: ubuntu-latest - steps: - - name: "Download teams.yml to know which label is for which team" - run: wget https://raw.githubusercontent.com/grafana/grafana/main/.github/teams.yml - - - name: "Determine which team to notify" - run: | - # Default to null values. - CHANNEL="null" - TEAM="null" - - echo "${{ github.event.label.name }} label added" - export CURRENT_LABEL="${{ github.event.label.name }}" # Enable the use of the label in yq evaluations - # yq is installed by default in ubuntu-latest - if [[ $(yq e 'keys | .[] | select(. == env(CURRENT_LABEL))' teams.yml ) ]]; then - # Check if we have a channel set to notify on comments. - if [[ $(yq '.[env(CURRENT_LABEL)] | has("channel-label")' teams.yml ) == true ]]; then - CHANNEL=$(yq '.[env(CURRENT_LABEL)].channel-label' teams.yml) - echo "Ready to send issue to channel ID ${CHANNEL}" - fi - - if [[ $(yq '.[env(CURRENT_LABEL)] | has("exclude-github-team")' teams.yml ) == true ]]; then - TEAM=$(yq '.[env(CURRENT_LABEL)].exclude-github-team' teams.yml) - echo "Will not send issue to channel if issue author is part of the team ${TEAM}" - fi - fi - - # set environment for next steps - echo "CHANNEL=${CHANNEL}" >> "$GITHUB_ENV" - echo "TEAM=${TEAM}" >> "$GITHUB_ENV" - - - name: "Prepare payload" - uses: frabert/replace-string-action@v2.5 - id: preparePayload - with: - # replace double quotes with single quotes to avoid breaking the JSON payload sent to Slack - string: ${{ github.event.issue.title }} - pattern: '"' - replace-with: "'" - flags: 'g' - - - name: Get Token - id: get_workflow_token - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a - with: - app_id: ${{ secrets.APP_GRAFANA_TEAM_CHECKER_ID }} - private_key: ${{ secrets.APP_GRAFANA_TEAM_CHECKER_KEY }} - - - name: "Check that issue author is not part of the team" - if: ${{ env.TEAM != 'null' }} - run: | - response=$(gh api /orgs/grafana/teams/${{ env.TEAM }}/memberships/${{ github.event.issue.user.login }} -i -H "Accept: application/vnd.github.v3+json") - STATUS_CODE=$(echo "$response" | head -n 1 | cut -d' ' -f2) - if [ "$STATUS_CODE" -eq "404" ]; then - echo "The user was not found in the team." - echo "USER_FOUND=false" >> "$GITHUB_ENV" - else - echo "The user was potentially found in the team" - echo "USER_FOUND=maybe" >> "$GITHUB_ENV" - fi - env: - GITHUB_TOKEN: ${{ steps.get_workflow_token.outputs.token }} - - - name: "Send Slack notification" - if: ${{ (env.CHANNEL != 'null') && ((env.USER_FOUND == 'false') || (env.TEAM != 'null')) }} - uses: slackapi/slack-github-action@v1.26.0 - with: - payload: > - { - "icon_emoji": ":grafana:", - "username": "Grafana issue labeled", - "text": "Issue \"${{ steps.preparePayload.outputs.replaced }}\" labeled \"${{ github.event.label.name }}\": ${{ github.event.issue.html_url }}, please triage.", - "channel": "${{ env.CHANNEL }}" - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/HALL_OF_FAME.md b/HALL_OF_FAME.md index a110543906b..ec3ac31b6ae 100644 --- a/HALL_OF_FAME.md +++ b/HALL_OF_FAME.md @@ -3,4 +3,5 @@ List of previous team members that have had a big impact on the company or the product and contributed during a long period of time. - Hugo Häggmark ([Björn Lundén](https://www.bjornlunden.se/)) -- Marcus Efraimsson +- [Marcus Efraimsson](https://github.com/marefr) +- [Giordano Ricci](https://github.com/elfo404) diff --git a/apps/playlist/go.mod b/apps/playlist/go.mod index e70e99db0eb..bbf0242220d 100644 --- a/apps/playlist/go.mod +++ b/apps/playlist/go.mod @@ -50,6 +50,7 @@ require ( github.com/prometheus/procfs v0.15.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/otel v1.32.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect @@ -60,7 +61,7 @@ require ( go.opentelemetry.io/otel/trace v1.32.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect golang.org/x/net v0.31.0 // indirect - golang.org/x/oauth2 v0.23.0 // indirect + golang.org/x/oauth2 v0.24.0 // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/term v0.26.0 // indirect golang.org/x/text v0.20.0 // indirect diff --git a/apps/playlist/go.sum b/apps/playlist/go.sum index 5b33274f339..ff833995d73 100644 --- a/apps/playlist/go.sum +++ b/apps/playlist/go.sum @@ -106,8 +106,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -143,8 +143,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md b/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md index 0cca046cbdf..1ec0242f7dd 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md @@ -56,8 +56,6 @@ This section describes how to: In both cases, the assignment applies only to the user, team or service account within the affected organization, and no other organizations. For example, if you grant the user the **Data source editor** role in the **Main** organization, then the user can edit data sources in the **Main** organization, but not in other organizations. -
- **Before you begin:** - [Plan your RBAC rollout strategy](ref:plan-rbac-rollout-strategy). @@ -71,8 +69,6 @@ In both cases, the assignment applies only to the user, team or service account - Your Grafana user can also assign fixed role if it has either the `fixed:roles:writer` fixed role assigned to the same organization to which you are assigning RBAC to a user, or a custom role with `users.roles:add` and `users.roles:remove` permissions. - Your own user account must have the roles you are granting. For example, if you would like to grant the `fixed:users:writer` role to a team, you must have that role yourself. -
- **To assign a fixed role to a user, team or service account:** 1. Sign in to Grafana. @@ -93,6 +89,35 @@ In both cases, the assignment applies only to the user, team or service account 1. Select a role within an organization that you want to assign to the user. 1. Click **Save**. +## Assign fixed or custom roles using the HTTP API + +This section describes how to: + +- Assign a fixed role to a user, team or service account as an organization administrator. + +**Before you begin:** + +- [Plan your RBAC rollout strategy](ref:plan-rbac-rollout-strategy). +- Identify the fixed roles that you want to assign to the user, team or service account. + + For more information about available fixed roles, refer to [RBAC role definitions](ref:rbac-role-definitions). + +- Ensure that your own user account has the correct permissions: + - If you are assigning permissions to a user, team or service account within an organization, you must have organization administrator or server administrator permissions. + - If you are assigning permissions to a user who belongs to multiple organizations, you must have server administrator permissions. + - Your Grafana user can also assign fixed role if it has either the `fixed:roles:writer` fixed role assigned to the same organization to which you are assigning RBAC to a user, or a custom role with `users.roles:add` and `users.roles:remove` permissions. + - Your own user account must have the roles you are granting. For example, if you would like to grant the `fixed:users:writer` role to a team, you must have that role yourself. + +**To assign a fixed role to a user or service account:** + +Refer to the [RBAC HTTP API for user role assignment](/docs/grafana//developers/http_api/access_control/#add-a-user-role-assignment). + +The service account ID can be used in place of the user ID. + +**To assign a fixed role to a team:** + +Refer to the [RBAC HTTP API for team role assignment](/docs/grafana//developers/http_api/access_control/#add-a-team-role-assignment). + ## Assign fixed or custom roles to a team using provisioning Instead of using the Grafana role picker, you can use file-based provisioning to assign fixed roles to teams. If you have a large number of teams, provisioning can provide an easier approach to assigning and managing role assignments. diff --git a/docs/sources/administration/service-accounts/_index.md b/docs/sources/administration/service-accounts/_index.md index eac4ac84d1e..b853496c094 100644 --- a/docs/sources/administration/service-accounts/_index.md +++ b/docs/sources/administration/service-accounts/_index.md @@ -161,8 +161,8 @@ By default, service account tokens don't have an expiration date, meaning they w ## Assign roles to a service account in Grafana -You can assign roles to a Grafana service account to control access for the associated service account tokens. -You can assign roles to a service account using the Grafana UI or via the API. For more information about assigning a role to a service account via the API, refer to [Update service account using the HTTP API](ref:api-update-service-account). +You can assign organization roles (`Viewer`, `Editor`, `Admin`) to a Grafana service account to control access for the associated service account tokens. +You can assign organization roles to a service account using the Grafana UI or via the API. For more information about assigning a role to a service account via the API, refer to [Update service account using the HTTP API](ref:api-update-service-account). In [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/), you can also [assign RBAC roles](ref:rbac-assign-rbac-roles) to grant very specific permissions to applications that interact with Grafana. diff --git a/docs/sources/alerting/alerting-rules/templates/_index.md b/docs/sources/alerting/alerting-rules/templates/_index.md index 77ef6073989..d5ce1572772 100644 --- a/docs/sources/alerting/alerting-rules/templates/_index.md +++ b/docs/sources/alerting/alerting-rules/templates/_index.md @@ -18,16 +18,16 @@ labels: title: Template annotations and labels weight: 500 refs: - labels: + reference-labels: - pattern: /docs/grafana/ - destination: /docs/grafana//alerting/fundamentals/alert-rules/annotation-label/#labels + destination: /docs/grafana//alerting/alerting-rules/templates/reference/#labels - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/#labels - values: + destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templates/reference/#labels + reference-values: - pattern: /docs/grafana/ - destination: /docs/grafana//alerting/fundamentals/alert-rules/annotation-label/#values + destination: /docs/grafana//alerting/alerting-rules/templates/reference/#values - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/#values + destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templates/reference/#values annotations: - pattern: /docs/grafana/ destination: /docs/grafana//alerting/fundamentals/alert-rules/annotation-label/#annotations @@ -92,7 +92,7 @@ Refer to [Templates Introduction](ref:intro-to-templates) for a more detailed ex Both types of templates are written in the Go templating system. However, it's important to understand that variables and functions used in notification templates are different from those used in annotation and label templates. -1. **Template annotations and labels**: These templates add extra information to individual alert instances. Template variables like [`$labels`](ref:labels) and [`$values`](ref:values) represent alert query data of the individual alert instance. +1. **Template annotations and labels**: These templates add extra information to individual alert instances. Template variables like [`$labels`](ref:reference-labels) and [`$values`](ref:reference-values) represent alert query data of the individual alert instance. 1. **Template notifications**: Notification templates format the notification content for a group of alerts. Variables like [`.Alerts`](ref:notification-data-reference) include all firing and resolved alerts in the notification. ## Template annotations @@ -111,11 +111,11 @@ CPU usage has exceeded 80% for the last 5 minutes. However, if you want to display dynamic query values in annotations, you need to use template code. Common use cases include: -- Displaying the query value or threshold that triggered the alert. -- Highlighting label information that identifies the alert, such as environment, region, or priority. +- Displaying the query value that triggered the alert. +- Highlighting label information that identifies the alert, such as the environment, instance, or region. - Providing specific instructions based on query values. -- Customizing runbook links depending on query or label values. -- Including contact information based on alert labels. +- Customizing runbook links depending on query labels. +- Including contact information based on query labels. For instance, you can template the previous example to display the specific instance and CPU value that triggered the alert. diff --git a/docs/sources/alerting/alerting-rules/templates/examples.md b/docs/sources/alerting/alerting-rules/templates/examples.md index 47e562544a2..e31cda2f626 100644 --- a/docs/sources/alerting/alerting-rules/templates/examples.md +++ b/docs/sources/alerting/alerting-rules/templates/examples.md @@ -115,7 +115,9 @@ CPU usage has exceeded 80% (81.2345) for the last 5 minutes. ### Include labels for extra details -To provide additional context, you can include labels from the query. For instance, access the [`$labels`](ref:reference-labels) variable to display a label that informs about the affected instance: +To provide additional context, you can include labels from the query using the [`$labels`](ref:reference-labels) variable. + +For instance, the previous case could also include the affected `instance`. ``` CPU usage for {{ $labels.instance }} has exceeded 80% ({{ $values.A.Value }}) for the last 5 minutes. @@ -125,14 +127,14 @@ CPU usage for {{ $labels.instance }} has exceeded 80% ({{ $values.A.Value }}) fo CPU usage for Instance 1 has exceeded 80% (81.2345) for the last 5 minutes. ``` -Annotations can also be used to provide a summary of key alert labels, such as the environment and alert severity. For instance, you can display a summary of the alert with important labels like: +You can incorporate any labels returned by the query into the template. For instance, the following template includes information about the environment and region where the alert occurred. ``` -Alert triggered in {{ $labels.environment }} with severity {{ $labels.severity }} +Alert triggered in {{ $labels.environment }} on {{ $labels.region }} region. ``` ```template_output -Alert triggered in production with severity critical. +Alert triggered in production on AMER region. ``` ### Print a range query diff --git a/docs/sources/alerting/alerting-rules/templates/reference.md b/docs/sources/alerting/alerting-rules/templates/reference.md index f33a8632cb3..8df98144878 100644 --- a/docs/sources/alerting/alerting-rules/templates/reference.md +++ b/docs/sources/alerting/alerting-rules/templates/reference.md @@ -16,6 +16,11 @@ title: Annotation and label template reference menuTitle: Template reference weight: 101 refs: + label-types: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/alert-rules/annotation-label/#label-types + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/#label-types notification-template-reference: - pattern: /docs/grafana/ destination: /docs/grafana//alerting/configure-notifications/template-notifications/reference/ @@ -72,15 +77,15 @@ The following variables are available when templating annotations and labels: | Variables | Description | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| [$labels](#labels) | Contains all labels from the query. | +| [$labels](#labels) | Contains all labels from the query, only query labels. | | [$values](#values) | Contains the labels and floating point values of all instant queries and expressions, indexed by their Ref IDs. | | [$value](#value) | A string containing the labels and values of all instant queries; threshold, reduce and math expressions, and classic conditions in the alert rule. | ### $labels -The `$labels` variable contains all labels from the query. +The `$labels` variable contains all labels from the query. It excludes [user-configured and reserved labels](ref:label-types), containing only query labels. -{{< figure src="/media/docs/alerting/query-labels-and_value.png" max-width="1200px" caption="An alert rule displaying labels and value from a query" >}} +{{< figure src="/media/docs/alerting/query-labels-and-values.png" max-width="1200px" caption="An alert rule displaying labels and value from a query." >}} For example, suppose you have a query that returns CPU usage for all of your servers, and you have an alert rule that fires when any of your servers have exceeded 80% CPU usage for the last 5 minutes. You want to add a summary annotation to the alert that tells you which server is experiencing high CPU usage. With the `$labels` variable you can write a template that prints a human-readable sentence such as: diff --git a/docs/sources/alerting/configure-notifications/template-notifications/reference.md b/docs/sources/alerting/configure-notifications/template-notifications/reference.md index e8921ab6bea..501b1e5331f 100644 --- a/docs/sources/alerting/configure-notifications/template-notifications/reference.md +++ b/docs/sources/alerting/configure-notifications/template-notifications/reference.md @@ -17,6 +17,11 @@ title: Notification template reference menuTitle: Template reference weight: 102 refs: + label-types: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/alert-rules/annotation-label/#label-types + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/#label-types alert-rule-template-reference: - pattern: /docs/grafana/ destination: /docs/grafana//alerting/alerting-rules/templates/reference/ @@ -88,7 +93,7 @@ Here's an example that prints all available notification data from dot (`.`): | Name | Type | Description | | -------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `Status` | string | Firing or resolved. | -| `Labels` | [KV](#kv) | The labels for this alert. | +| `Labels` | [KV](#kv) | The labels for this alert. It includes all [types of labels](ref:label-types). | | `Annotations` | [KV](#kv) | The annotations for this alert. | | `StartsAt` | [Time](#time) | The time the alert fired | | `EndsAt` | [Time](#time) | Only set if the end time of an alert is known. Otherwise set to a configurable timeout period from the time since the last alert was received. | diff --git a/docs/sources/alerting/fundamentals/alert-rules/annotation-label.md b/docs/sources/alerting/fundamentals/alert-rules/annotation-label.md index 9640d3c3fe0..41d3c4c0220 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/annotation-label.md +++ b/docs/sources/alerting/fundamentals/alert-rules/annotation-label.md @@ -87,11 +87,13 @@ Labels that you manually configure in the alert rule to identify the generated a Additionally, you can use a [template](ref:templates) to customize the label value and generate dynamic values from query data. -**Data source query labels** +**Query labels** -For example, if you are monitoring temperature readings and each time series for these readings has a `sensor_id`, and a `location` label, an alert instance might have the labels `{sensor_id="1",location="NY"}`, while another alert instance might have `{sensor_id="2",location="WA"}`. +Query labels are labels returned by the data source query. -Data source query labels labels are also used to generate multiple alert instances from the same alert rule, helping to distinguish alerts from different data. +{{< figure src="/media/docs/alerting/query-labels-and-values.png" max-width="1200px" caption="An alert rule query returning labels from the query." >}} + +Query labels can generate multiple alert instances from the same alert rule, helping to distinguish alerts from different data. In this example, the `instance` label generates an alert instance for each server. **Reserved labels** diff --git a/docs/sources/datasources/tempo/query-editor/_index.md b/docs/sources/datasources/tempo/query-editor/_index.md index d2f37e700f7..b561a54bdcd 100644 --- a/docs/sources/datasources/tempo/query-editor/_index.md +++ b/docs/sources/datasources/tempo/query-editor/_index.md @@ -75,7 +75,7 @@ You can use the **Search** query builder's user interface to select options to s These selections generate a TraceQL query. Any query generated using **Search** query builder can be transferred to the **TraceQL** query editor, where you can edit the query directly. -To learn more about how to query by TraceQL, refer to the [TraceQL documentation](/docs/tempo/latest/traceql). +To learn more about how to query by TraceQL, refer to the [TraceQL documentation](https://grafana.com/docs/tempo//traceql). ## Choose a query editing mode @@ -123,15 +123,15 @@ Using the service graph view, you can: - Determine how long the slowest queries in your service take to complete. - Examine all traces that contain spans of particular interest based on rate, error, and duration values (RED signals). -For more information about the service graph, refer to [Service graph](../service-graph/). +For more information about the service graph, refer to [Service graph](../service-graph). ![Screenshot of the Service Graph view](/media/docs/grafana/data-sources/tempo/query-editor/tempo-ds-query-service-graph.png) ## Use TraceQL panels in dashboards -To add TraceQL panels to your dashboard, refer to the [Traces panel documentation](/docs/grafana/latest/panels-visualizations/visualizations/traces/). +To add TraceQL panels to your dashboard, refer to the [Traces panel documentation](https://grafana.com/docs/grafana//panels-visualizations/visualizations/traces/). -To learn more about Grafana dashboards, refer to the [Use dashboards documentation](/docs/grafana/latest/dashboards/use-dashboards/). +To learn more about Grafana dashboards, refer to the [Use dashboards documentation](/docs/grafana//dashboards/use-dashboards/). ## Set options for query builder and editor diff --git a/go.mod b/go.mod index 3d0bf7d3c7c..ad92b1d94ba 100644 --- a/go.mod +++ b/go.mod @@ -74,8 +74,8 @@ require ( github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad github.com/grafana/alerting v0.0.0-20241021123319-be61d61f71e7 // @grafana/alerting-backend - github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a // @grafana/identity-access-team - github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d // @grafana/identity-access-team + github.com/grafana/authlib v0.0.0-20241202090931-5f1dcf611f9c // @grafana/identity-access-team + github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 // @grafana/identity-access-team github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d // @grafana/dataviz-squad github.com/grafana/cuetsy v0.1.11 // @grafana/grafana-as-code github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics @@ -89,7 +89,7 @@ require ( github.com/grafana/grafana-cloud-migration-snapshot v1.3.0 // @grafana/grafana-operator-experience-squad github.com/grafana/grafana-google-sdk-go v0.1.0 // @grafana/partner-datasources github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 // @grafana/grafana-backend-group - github.com/grafana/grafana-plugin-sdk-go v0.259.4 // @grafana/plugins-platform-backend + github.com/grafana/grafana-plugin-sdk-go v0.260.1 // @grafana/plugins-platform-backend github.com/grafana/grafana/pkg/aggregator v0.0.0-20240813192817-1b0e6b5c09b2 // @grafana/grafana-app-platform-squad github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240821155123-6891eb1d35da // @grafana/grafana-app-platform-squad github.com/grafana/grafana/pkg/apiserver v0.0.0-20240821155123-6891eb1d35da // @grafana/grafana-app-platform-squad @@ -148,7 +148,7 @@ require ( github.com/spf13/cobra v1.8.1 // @grafana/grafana-app-platform-squad github.com/spf13/pflag v1.0.5 // @grafana-app-platform-squad github.com/spyzhov/ajson v0.9.0 // @grafana/grafana-app-platform-squad - github.com/stretchr/testify v1.9.0 // @grafana/grafana-backend-group + github.com/stretchr/testify v1.10.0 // @grafana/grafana-backend-group github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf // @grafana/grafana-backend-group github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f // @grafana/grafana-backend-group github.com/urfave/cli v1.22.16 // indirect; @grafana/grafana-backend-group @@ -175,7 +175,7 @@ require ( golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // @grafana/alerting-backend golang.org/x/mod v0.22.0 // indirect; @grafana/grafana-backend-group golang.org/x/net v0.31.0 // @grafana/oss-big-tent @grafana/partner-datasources - golang.org/x/oauth2 v0.23.0 // @grafana/identity-access-team + golang.org/x/oauth2 v0.24.0 // @grafana/identity-access-team golang.org/x/sync v0.9.0 // @grafana/alerting-backend golang.org/x/text v0.20.0 // @grafana/grafana-backend-group golang.org/x/time v0.6.0 // @grafana/grafana-backend-group diff --git a/go.sum b/go.sum index f6e09e827eb..c75c8146d48 100644 --- a/go.sum +++ b/go.sum @@ -2292,10 +2292,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/alerting v0.0.0-20241021123319-be61d61f71e7 h1:lsM/QscEX+ZDIJm48ynQscH+msETyGYV6ug8L4f2DtM= github.com/grafana/alerting v0.0.0-20241021123319-be61d61f71e7/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU= -github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a h1:X3mroOOXdryRY6SEIQp7S6l1oxt964ywSmcknszHwn0= -github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a/go.mod h1:XFhcSCEDeOgV43x41we7mRBcizDpsTScb7XOuYipQZg= -github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d h1:7nZfaXdC4Xc2ocMz5/Bx/3EsaEO34KsmA2RRcuogLnc= -github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A= +github.com/grafana/authlib v0.0.0-20241202090931-5f1dcf611f9c h1:10aFnI5iFk0XSw19FD26oS4nIwCunfbkYT800hpHBzo= +github.com/grafana/authlib v0.0.0-20241202090931-5f1dcf611f9c/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI= +github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk= +github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A= github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk= github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= github.com/grafana/cue v0.0.0-20230926092038-971951014e3f h1:TmYAMnqg3d5KYEAaT6PtTguL2GjLfvr6wnAX8Azw6tQ= @@ -2331,8 +2331,8 @@ github.com/grafana/grafana-google-sdk-go v0.1.0/go.mod h1:Vo2TKWfDVmNTELBUM+3lkr github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 h1:r+mU5bGMzcXCRVAuOrTn54S80qbfVkvTdUJZfSfTNbs= github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ= github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk= -github.com/grafana/grafana-plugin-sdk-go v0.259.4 h1:Qt/Ic+7K7n7z4ncZs29q2OhOOPKWjE5+ms2ckeYlTo4= -github.com/grafana/grafana-plugin-sdk-go v0.259.4/go.mod h1:BS2B+lEHC4KtOViiS4C5x9SsR1ihPHAzesGOpaPYeCs= +github.com/grafana/grafana-plugin-sdk-go v0.260.1 h1:KzbooQP9mv/9CPsn+SoUwGuomA8oUxO0iuIq6Rg/ekE= +github.com/grafana/grafana-plugin-sdk-go v0.260.1/go.mod h1:JriieK5Oc5v120QKhMs/LO55N0P3YI2ttEiVT1wfYsw= github.com/grafana/grafana/apps/playlist v0.0.0-20241105090059-facca37f4d1f h1:zZN/Jy7PjoqtrMiBRV5O3x4xAArcSbUznuyAPACrKXI= github.com/grafana/grafana/apps/playlist v0.0.0-20241105090059-facca37f4d1f/go.mod h1:e97Zxn1WX4Wn9TXEvwTjMNwU6yrjX/K7uVNSCZyEwxY= github.com/grafana/grafana/pkg/aggregator v0.0.0-20240813192817-1b0e6b5c09b2 h1:2H9x4q53pkfUGtSNYD1qSBpNnxrFgylof/TYADb5xMI= @@ -3230,8 +3230,9 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= @@ -3746,8 +3747,8 @@ golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2 golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/go.work.sum b/go.work.sum index 8e10716c32c..644b339ee6b 100644 --- a/go.work.sum +++ b/go.work.sum @@ -987,6 +987,7 @@ github.com/grafana/alerting v0.0.0-20240830172655-aa466962ea18 h1:3cQ+d+fkNL2Eqp github.com/grafana/alerting v0.0.0-20240830172655-aa466962ea18/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4= github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10 h1:oDbLKM34O+JUF9EQFS+9aYhdYoeNfUpXqNjFCLIxwF4= github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4= +github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk= github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I= github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw= github.com/grafana/cog v0.0.4/go.mod h1:lzetOuhGUl/JaSACiJoHvBokf9/fS6PEFaWZvnQu2vs= @@ -1001,6 +1002,8 @@ github.com/grafana/go-json v0.0.0-20241115232854-f14426c40ff2 h1:8xGrYqQ1GM4aaMk github.com/grafana/go-json v0.0.0-20241115232854-f14426c40ff2/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/grafana/gomemcache v0.0.0-20240229205252-cd6a66d6fb56/go.mod h1:PGk3RjYHpxMM8HFPhKKo+vve3DdlPUELZLSDEFehPuU= github.com/grafana/grafana-app-sdk v0.19.0/go.mod h1:y0BgzYxc+a7CwOqkwUhN9zXd5cgZJjd2zAbgHEd/xzo= +github.com/grafana/grafana-plugin-sdk-go v0.260.1 h1:KzbooQP9mv/9CPsn+SoUwGuomA8oUxO0iuIq6Rg/ekE= +github.com/grafana/grafana-plugin-sdk-go v0.260.1/go.mod h1:JriieK5Oc5v120QKhMs/LO55N0P3YI2ttEiVT1wfYsw= github.com/grafana/pyroscope-go/godeltaprof v0.1.6/go.mod h1:Tk376Nbldo4Cha9RgiU7ik8WKFkNpfds98aUzS8omLE= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU= @@ -1444,6 +1447,8 @@ github.com/stoewer/parquet-cli v0.0.7 h1:rhdZODIbyMS3twr4OM3am8BPPT5pbfMcHLH93wh github.com/stoewer/parquet-cli v0.0.7/go.mod h1:bskxHdj8q3H1EmfuCqjViFoeO3NEvs5lzZAQvI8Nfjk= github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo= github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e h1:mOtuXaRAbVZsxAHVdPR3IjfmN8T1h2iczJLynhLybf8= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/substrait-io/substrait-go v0.4.2 h1:buDnjsb3qAqTaNbOR7VKmNgXf4lYQxWEcnSGUWBtmN8= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/tdewolff/minify/v2 v2.12.9 h1:dvn5MtmuQ/DFMwqf5j8QhEVpPX6fi3WGImhv8RUB4zA= @@ -1792,6 +1797,8 @@ golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/kindsv2/go.mod b/kindsv2/go.mod index 820ad5a3065..635f2b721fb 100644 --- a/kindsv2/go.mod +++ b/kindsv2/go.mod @@ -32,6 +32,7 @@ require ( github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/ugorji/go/codec v1.2.11 // indirect github.com/yalue/merged_fs v1.3.0 // indirect golang.org/x/mod v0.22.0 // indirect diff --git a/kindsv2/go.sum b/kindsv2/go.sum index 79ff5b0bee6..7af251fe06b 100644 --- a/kindsv2/go.sum +++ b/kindsv2/go.sum @@ -67,8 +67,8 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/yalue/merged_fs v1.3.0 h1:qCeh9tMPNy/i8cwDsQTJ5bLr6IRxbs6meakNE5O+wyY= diff --git a/package.json b/package.json index 4a0c286cd52..1976386483f 100644 --- a/package.json +++ b/package.json @@ -53,9 +53,9 @@ "watch": "yarn start -d watch,start core:start --watchTheme", "ci:test-frontend": "yarn run test:ci", "i18n:stats": "node ./scripts/cli/reportI18nStats.mjs", - "betterer": "betterer", + "betterer": "betterer --tsconfig ./scripts/cli/tsconfig.json", "betterer:json": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/bettererResultsToJson.ts", - "betterer:merge": "betterer merge", + "betterer:merge": "betterer merge --tsconfig ./scripts/cli/tsconfig.json", "betterer:stats": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/reportBettererStats.ts", "betterer:issues": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/generateBettererIssues.ts", "generate-icons-bundle-cache-file": "node ./scripts/generate-icon-bundle.js", @@ -87,7 +87,7 @@ "@playwright/test": "1.48.2", "@pmmmwh/react-refresh-webpack-plugin": "0.5.15", "@react-types/button": "3.10.1", - "@react-types/menu": "3.9.12", + "@react-types/menu": "3.9.13", "@react-types/overlays": "3.8.10", "@react-types/shared": "3.25.0", "@rtk-query/codegen-openapi": "^1.2.0", diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index ce24ddcb93f..a2e4b2bd953 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -620,7 +620,7 @@ export class PrometheusDatasource // it is used in metric_find_query.ts // and in Tempo here grafana/public/app/plugins/datasource/tempo/QueryEditor/ServiceGraphSection.tsx async getTagKeys(options: DataSourceGetTagKeysOptions): Promise { - if (config.featureToggles.promQLScope && !!options) { + if (config.featureToggles.promQLScope && (options?.scopes?.length ?? 0) > 0) { const suggestions = await this.languageProvider.fetchSuggestions( options.timeRange, options.queries, diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index af7a754703a..68c418c2813 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -89,7 +89,7 @@ "rc-slider": "11.1.7", "rc-time-picker": "^3.7.3", "rc-tooltip": "6.2.1", - "react-calendar": "5.1.0", + "react-calendar": "^5.1.0", "react-colorful": "5.6.1", "react-custom-scrollbars-2": "4.5.0", "react-dropzone": "14.3.5", diff --git a/pkg/aggregator/go.mod b/pkg/aggregator/go.mod index db6c93d9e6e..7140ba4e23d 100644 --- a/pkg/aggregator/go.mod +++ b/pkg/aggregator/go.mod @@ -4,11 +4,11 @@ go 1.23.1 require ( github.com/emicklei/go-restful/v3 v3.11.0 - github.com/grafana/grafana-plugin-sdk-go v0.259.4 + github.com/grafana/grafana-plugin-sdk-go v0.260.1 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240808213237-f4d2e064f435 github.com/grafana/grafana/pkg/semconv v0.0.0-20240808213237-f4d2e064f435 github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 go.opentelemetry.io/otel v1.32.0 k8s.io/api v0.31.3 k8s.io/apimachinery v0.31.3 @@ -138,7 +138,7 @@ require ( golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect golang.org/x/mod v0.22.0 // indirect golang.org/x/net v0.31.0 // indirect - golang.org/x/oauth2 v0.23.0 // indirect + golang.org/x/oauth2 v0.24.0 // indirect golang.org/x/sync v0.9.0 // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/term v0.26.0 // indirect diff --git a/pkg/aggregator/go.sum b/pkg/aggregator/go.sum index cd181d57ca0..f5a8b026dc4 100644 --- a/pkg/aggregator/go.sum +++ b/pkg/aggregator/go.sum @@ -130,8 +130,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grafana/grafana-plugin-sdk-go v0.259.4 h1:Qt/Ic+7K7n7z4ncZs29q2OhOOPKWjE5+ms2ckeYlTo4= -github.com/grafana/grafana-plugin-sdk-go v0.259.4/go.mod h1:BS2B+lEHC4KtOViiS4C5x9SsR1ihPHAzesGOpaPYeCs= +github.com/grafana/grafana-plugin-sdk-go v0.260.1 h1:KzbooQP9mv/9CPsn+SoUwGuomA8oUxO0iuIq6Rg/ekE= +github.com/grafana/grafana-plugin-sdk-go v0.260.1/go.mod h1:JriieK5Oc5v120QKhMs/LO55N0P3YI2ttEiVT1wfYsw= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240808213237-f4d2e064f435 h1:lmw60EW7JWlAEvgggktOyVkH4hF1m/+LSF/Ap0NCyi8= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240808213237-f4d2e064f435/go.mod h1:ORVFiW/KNRY52lNjkGwnFWCxNVfE97bJG2jr2fetq0I= github.com/grafana/grafana/pkg/semconv v0.0.0-20240808213237-f4d2e064f435 h1:SNEeqY22DrGr5E9kGF1mKSqlOom14W9+b1u4XEGJowA= @@ -298,8 +298,9 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= @@ -406,8 +407,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/api/folder.go b/pkg/api/folder.go index ba95523964f..047923e7950 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -49,7 +49,6 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID)) folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { - folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder)) folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder)) folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts)) @@ -64,6 +63,7 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz folderRoute.Post("/", handler.createFolder) folderRoute.Get("/", handler.getFolders) folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { + folderUidRoute.Put("/", handler.updateFolder) folderUidRoute.Delete("/", handler.deleteFolder) folderUidRoute.Get("/", handler.getFolder) }) @@ -71,17 +71,11 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder)) folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders)) folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { + folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder)) folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder)) folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID)) }) } - // Only adding support for some routes with the k8s handler for now. Include the rest here. - if false { - handler := newFolderK8sHandler(hs) - folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { - folderUidRoute.Put("/:uid", handler.updateFolder) - }) - } }) } @@ -969,7 +963,7 @@ func (fk8s *folderK8sHandler) toDTO(c *contextmodel.ReqContext, fold *folder.Fol UpdatedBy: updater, Updated: fold.Updated, // #TODO version doesn't seem to be used--confirm or set it properly - Version: 1, + Version: fold.Version, AccessControl: acMetadata, ParentUID: fold.ParentUID, }, nil diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 6a45068ea23..58f4ac2a54a 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -5,17 +5,24 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httptest" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + clientrest "k8s.io/client-go/rest" "github.com/grafana/grafana/pkg/api/dtos" + folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + conversions "github.com/grafana/grafana/pkg/registry/apis/folders" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -24,6 +31,7 @@ import ( "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web/webtest" ) @@ -510,3 +518,232 @@ func TestFolderGetAPIEndpoint(t *testing.T) { }) } } + +type mockClientConfigProvider struct { + host string +} + +func (m mockClientConfigProvider) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Config { + return &clientrest.Config{ + Host: m.host, + } +} + +func (m mockClientConfigProvider) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) {} + +func TestUpdateFolderLegacyAndUnifiedStorage(t *testing.T) { + testuser := &user.User{ID: 99, UID: "fdxsqt7t5ryf4a", Login: "testuser"} + + legacyFolder := folder.Folder{ + UID: "ady4yobv315a8e", + Title: "Example folder 226", + URL: "/dashboards/f/ady4yobv315a8e/example-folder-226", + CreatedBy: 99, + CreatedByUID: "fdxsqt7t5ryf4a", + Created: time.Date(2024, time.November, 29, 0, 42, 34, 0, time.UTC), + UpdatedBy: 99, + UpdatedByUID: "fdxsqt7t5ryf4a", + Updated: time.Date(2024, time.November, 29, 0, 42, 34, 0, time.UTC), + Version: 3, + } + + namespacer := func(_ int64) string { return "1" } + unifiedStorageFolder, err := conversions.LegacyFolderToUnstructured(&legacyFolder, namespacer) + require.NoError(t, err) + + expectedFolder := dtos.Folder{ + UID: legacyFolder.UID, + OrgID: 0, + Title: legacyFolder.Title, + URL: legacyFolder.URL, + HasACL: false, + CanSave: false, + CanEdit: true, + CanAdmin: false, + CanDelete: false, + CreatedBy: "testuser", + Created: legacyFolder.Created, + UpdatedBy: "testuser", + Updated: legacyFolder.Updated, + Version: legacyFolder.Version, + } + + mux := http.NewServeMux() + mux.HandleFunc("PUT /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/ady4yobv315a8e", func(w http.ResponseWriter, req *http.Request) { + err := json.NewEncoder(w).Encode(unifiedStorageFolder) + require.NoError(t, err) + }) + + folderApiServerMock := httptest.NewServer(mux) + defer folderApiServerMock.Close() + + t.Run("happy path", func(t *testing.T) { + type testCase struct { + description string + folderUID string + legacyFolder folder.Folder + expectedFolder dtos.Folder + expectedFolderServiceError error + unifiedStorageEnabled bool + unifiedStorageMode grafanarest.DualWriterMode + expectedCode int + } + + tcs := []testCase{ + { + description: "Happy Path - Legacy", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageEnabled: false, + }, + { + description: "Happy Path - Unified storage, mode 1", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageEnabled: true, + unifiedStorageMode: grafanarest.Mode1, + }, + { + description: "Happy Path - Unified storage, mode 2", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageEnabled: true, + unifiedStorageMode: grafanarest.Mode2, + }, + { + description: "Happy Path - Unified storage, mode 3", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageEnabled: true, + unifiedStorageMode: grafanarest.Mode3, + }, + { + description: "Happy Path - Unified storage, mode 4", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageEnabled: true, + unifiedStorageMode: grafanarest.Mode4, + }, + { + description: "Folder Not Found - Legacy", + expectedCode: http.StatusNotFound, + legacyFolder: legacyFolder, + folderUID: "notfound", + expectedFolder: expectedFolder, + unifiedStorageEnabled: false, + expectedFolderServiceError: dashboards.ErrFolderNotFound, + }, + { + description: "Folder Not Found - Unified storage, mode 1", + expectedCode: http.StatusNotFound, + legacyFolder: legacyFolder, + folderUID: "notfound", + expectedFolder: expectedFolder, + unifiedStorageEnabled: true, + unifiedStorageMode: grafanarest.Mode1, + }, + { + description: "Folder Not Found - Unified storage, mode 2", + expectedCode: http.StatusNotFound, + legacyFolder: legacyFolder, + folderUID: "notfound", + expectedFolder: expectedFolder, + unifiedStorageEnabled: true, + unifiedStorageMode: grafanarest.Mode2, + }, + { + description: "Folder Not Found - Unified storage, mode 3", + expectedCode: http.StatusNotFound, + legacyFolder: legacyFolder, + folderUID: "notfound", + expectedFolder: expectedFolder, + unifiedStorageEnabled: true, + unifiedStorageMode: grafanarest.Mode3, + }, + { + description: "Folder Not Found - Unified storage, mode 4", + expectedCode: http.StatusNotFound, + legacyFolder: legacyFolder, + folderUID: "notfound", + expectedFolder: expectedFolder, + unifiedStorageEnabled: true, + unifiedStorageMode: grafanarest.Mode4, + }, + } + + for _, tc := range tcs { + t.Run(tc.description, func(t *testing.T) { + setUpRBACGuardian(t) + + cfg := setting.NewCfg() + cfg.UnifiedStorage = map[string]setting.UnifiedStorageConfig{ + folderv0alpha1.RESOURCEGROUP: { + DualWriterMode: tc.unifiedStorageMode, + }, + } + + featuresArr := []any{featuremgmt.FlagNestedFolders} + if tc.unifiedStorageEnabled { + featuresArr = append(featuresArr, featuremgmt.FlagKubernetesFolders) + } + + server := SetupAPITestServer(t, func(hs *HTTPServer) { + hs.Cfg = cfg + hs.folderService = &foldertest.FakeService{ + ExpectedFolder: &tc.legacyFolder, + ExpectedError: tc.expectedFolderServiceError, + } + hs.QuotaService = quotatest.New(false, nil) + hs.SearchService = &mockSearchService{ + ExpectedResult: model.HitList{}, + } + hs.userService = &usertest.FakeUserService{ + ExpectedUser: testuser, + } + hs.Features = featuremgmt.WithFeatures( + featuresArr..., + ) + hs.clientConfigProvider = mockClientConfigProvider{ + host: folderApiServerMock.URL, + } + }) + + req := server.NewRequest(http.MethodPut, fmt.Sprintf("/api/folders/%s", tc.folderUID), strings.NewReader(`{"title":"new title"}`)) + req.Header.Set("Content-Type", "application/json") + webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByActionContext(context.Background(), []accesscontrol.Permission{ + {Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersAll}, + {Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("ady4yobv315a8e")}, + }), + }}) + + res, err := server.Send(req) + require.NoError(t, err) + + require.Equal(t, tc.expectedCode, res.StatusCode) + defer func() { require.NoError(t, res.Body.Close()) }() + + if tc.expectedCode == http.StatusOK { + body := dtos.Folder{} + require.NoError(t, json.NewDecoder(res.Body).Decode(&body)) + + //nolint:staticcheck + body.ID = 0 + body.Version = 0 + tc.expectedFolder.Version = 0 + require.Equal(t, tc.expectedFolder, body) + } + }) + } + }) +} diff --git a/pkg/api/short_url.go b/pkg/api/short_url.go index 6a1292c65b8..5c97b2803d7 100644 --- a/pkg/api/short_url.go +++ b/pkg/api/short_url.go @@ -46,12 +46,16 @@ func (hs *HTTPServer) redirectFromShortURL(c *contextmodel.ReqContext) { shortURL, err := hs.ShortURLService.GetShortURLByUID(c.Req.Context(), c.SignedInUser, shortURLUID) if err != nil { + // If we didn't get the URL for whatever reason, we redirect to the + // main page, otherwise we get into an endless loops of redirects, as + // we would try to redirect again. if shorturls.ErrShortURLNotFound.Is(err) { - hs.log.Debug("Not redirecting short URL since not found") + hs.log.Debug("Not redirecting short URL since not found", "uid", shortURLUID) + c.Redirect(hs.Cfg.AppURL, 308) return } - hs.log.Error("Short URL redirection error", "err", err) + c.Redirect(hs.Cfg.AppURL, 307) return } diff --git a/pkg/api/user.go b/pkg/api/user.go index 292aa7fc3cd..8c8fca56d11 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -281,7 +281,7 @@ func (hs *HTTPServer) StartEmailVerificaton(c *contextmodel.ReqContext) response return response.Error(http.StatusBadRequest, "Only users can verify their email", nil) } - if c.SignedInUser.IsEmailVerified() { + if c.SignedInUser.GetEmailVerified() { // email is already verified so we don't need to trigger the flow. return response.Respond(http.StatusNotModified, nil) } diff --git a/pkg/apimachinery/go.mod b/pkg/apimachinery/go.mod index d306b0d9f13..f17dfd4d45f 100644 --- a/pkg/apimachinery/go.mod +++ b/pkg/apimachinery/go.mod @@ -3,9 +3,9 @@ module github.com/grafana/grafana/pkg/apimachinery go 1.23.1 require ( - github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a // @grafana/identity-access-team - github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d // @grafana/identity-access-team - github.com/stretchr/testify v1.9.0 + github.com/grafana/authlib v0.0.0-20241202090931-5f1dcf611f9c // @grafana/identity-access-team + github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 // @grafana/identity-access-team + github.com/stretchr/testify v1.10.0 k8s.io/apimachinery v0.31.3 k8s.io/apiserver v0.31.3 k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 diff --git a/pkg/apimachinery/go.sum b/pkg/apimachinery/go.sum index d47f3912c5c..5a1f7c5a9bb 100644 --- a/pkg/apimachinery/go.sum +++ b/pkg/apimachinery/go.sum @@ -28,10 +28,10 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a h1:X3mroOOXdryRY6SEIQp7S6l1oxt964ywSmcknszHwn0= -github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a/go.mod h1:XFhcSCEDeOgV43x41we7mRBcizDpsTScb7XOuYipQZg= -github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d h1:7nZfaXdC4Xc2ocMz5/Bx/3EsaEO34KsmA2RRcuogLnc= -github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A= +github.com/grafana/authlib v0.0.0-20241202090931-5f1dcf611f9c h1:10aFnI5iFk0XSw19FD26oS4nIwCunfbkYT800hpHBzo= +github.com/grafana/authlib v0.0.0-20241202090931-5f1dcf611f9c/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI= +github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk= +github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -61,8 +61,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/pkg/apimachinery/identity/requester.go b/pkg/apimachinery/identity/requester.go index c8fbf6fd3e7..93f82c3394c 100644 --- a/pkg/apimachinery/identity/requester.go +++ b/pkg/apimachinery/identity/requester.go @@ -14,48 +14,59 @@ type Requester interface { // GetIdentityType returns the type for the requester GetIdentityType() claims.IdentityType + // IsIdentityType returns true if identity type for requester matches any expected identity type IsIdentityType(expected ...claims.IdentityType) bool + + // FIXME: remove and use GetIdentifier instead. // GetRawIdentifier returns only the identifier part of the UID, excluding the type GetRawIdentifier() string - // GetInternalID returns only the identifier part of the ID, excluding the type - GetInternalID() (int64, error) + // GetID returns namespaced internalID for the entity // Deprecated: use GetUID instead GetID() string + // GetEmail returns the email of the active entity. // Can be empty. GetEmail() string - // IsEmailVerified returns if email is verified for entity. - IsEmailVerified() bool + + // GetInternalID returns only the identifier part of the ID, excluding the type + GetInternalID() (int64, error) + // GetIsGrafanaAdmin returns true if the user is a server admin GetIsGrafanaAdmin() bool + + // FIXME: remove and use GetUsername instead // GetLogin returns the login of the active entity // Can be empty. GetLogin() string + // GetOrgID returns the ID of the active organization GetOrgID() int64 + // GetOrgRole returns the role of the active entity in the active organization. GetOrgRole() RoleType + // GetPermissions returns the permissions of the active entity. GetPermissions() map[string][]string + // GetGlobalPermissions returns the permissions of the active entity that are available across all organizations. GetGlobalPermissions() map[string][]string + // DEPRECATED: GetTeams returns the teams the entity is a member of. // Retrieve the teams from the team service instead of using this method. GetTeams() []int64 + // DEPRECATED: GetOrgName returns the name of the active organization. // Retrieve the organization name from the organization service instead of using this method. GetOrgName() string + // GetAuthID returns external id for entity. GetAuthID() string - // GetNamespace returns either "*" or the single namespace this requester has access to - // An empty value means the implementation has not specified a kubernetes namespace. - GetNamespace() string - // GetAuthenticatedBy returns the authentication method used to authenticate the entity. - GetAuthenticatedBy() string + // IsAuthenticatedBy returns true if entity was authenticated by any of supplied providers. IsAuthenticatedBy(providers ...string) bool + // IsNil returns true if the identity is nil // FIXME: remove this method once all services are using an interface IsNil() bool diff --git a/pkg/apimachinery/identity/static.go b/pkg/apimachinery/identity/static.go index e520e63348c..ab689716061 100644 --- a/pkg/apimachinery/identity/static.go +++ b/pkg/apimachinery/identity/static.go @@ -2,12 +2,13 @@ package identity import ( "fmt" + "strconv" authnlib "github.com/grafana/authlib/authn" "github.com/grafana/authlib/claims" ) -var _ Requester = &StaticRequester{} +var _ Requester = (*StaticRequester)(nil) // StaticRequester allows creating requester values explicitly. // It is helpful in tests! @@ -35,45 +36,63 @@ type StaticRequester struct { CacheKey string } -// Access implements Requester. -func (u *StaticRequester) GetAccess() claims.AccessClaims { - return &IDClaimsWrapper{Source: u} +// GetID returns typed id for the entity +func (u *StaticRequester) GetID() string { + return claims.NewTypeID(u.Type, strconv.FormatInt(u.UserID, 10)) } -// Identity implements Requester. -func (u *StaticRequester) GetIdentity() claims.IdentityClaims { - if u.IDTokenClaims != nil { - return authnlib.NewIdentityClaims(*u.IDTokenClaims) - } - return &IDClaimsWrapper{Source: u} -} - -// GetRawIdentifier implements Requester. func (u *StaticRequester) GetUID() string { - return fmt.Sprintf("%s:%s", u.Type, u.UserUID) + return claims.NewTypeID(u.Type, u.UserUID) } -// GetRawIdentifier implements Requester. -func (u *StaticRequester) GetRawIdentifier() string { +func (u *StaticRequester) GetIdentifier() string { return u.UserUID } -// GetInternalID implements Requester. -func (u *StaticRequester) GetInternalID() (int64, error) { - return u.UserID, nil -} - -// GetIdentityType implements Requester. func (u *StaticRequester) GetIdentityType() claims.IdentityType { return u.Type } -// IsIdentityType implements Requester. +func (u *StaticRequester) GetSubject() string { + return claims.NewTypeID(u.Type, strconv.FormatInt(u.UserID, 10)) +} + +func (u *StaticRequester) GetAudience() []string { + return []string{fmt.Sprintf("org:%d", u.OrgID)} +} + +func (u *StaticRequester) GetTokenPermissions() []string { + return []string{} +} + +func (u *StaticRequester) GetTokenDelegatedPermissions() []string { + return []string{} +} + +func (u *StaticRequester) GetEmail() string { + return u.Email +} + +func (u *StaticRequester) GetEmailVerified() bool { + return u.EmailVerified +} + +func (u *StaticRequester) GetUsername() string { + return u.Login +} + +func (u *StaticRequester) GetRawIdentifier() string { + return u.UserUID +} + +func (u *StaticRequester) GetInternalID() (int64, error) { + return u.UserID, nil +} + func (u *StaticRequester) IsIdentityType(expected ...claims.IdentityType) bool { return claims.IsIdentityType(u.GetIdentityType(), expected...) } -// GetExtra implements Requester. func (u *StaticRequester) GetExtra() map[string][]string { if u.IDToken != "" { return map[string][]string{"id-token": {u.IDToken}} @@ -81,12 +100,10 @@ func (u *StaticRequester) GetExtra() map[string][]string { return map[string][]string{} } -// GetGroups implements Requester. func (u *StaticRequester) GetGroups() []string { return []string{} } -// GetName implements Requester. func (u *StaticRequester) GetName() string { if u.Name != "" { return u.Name @@ -171,11 +188,6 @@ func (u *StaticRequester) HasUniqueId() bool { return u.UserID > 0 } -// GetID returns typed id for the entity -func (u *StaticRequester) GetID() string { - return claims.NewTypeID(u.Type, fmt.Sprintf("%d", u.UserID)) -} - func (u *StaticRequester) GetAuthID() string { return u.AuthID } @@ -202,12 +214,6 @@ func (u *StaticRequester) IsNil() bool { return u == nil } -// GetEmail returns the email of the active entity -// Can be empty. -func (u *StaticRequester) GetEmail() string { - return u.Email -} - func (u *StaticRequester) IsEmailVerified() bool { return u.EmailVerified } diff --git a/pkg/apimachinery/identity/wrapper.go b/pkg/apimachinery/identity/wrapper.go deleted file mode 100644 index 09eab3eea41..00000000000 --- a/pkg/apimachinery/identity/wrapper.go +++ /dev/null @@ -1,109 +0,0 @@ -package identity - -import ( - "fmt" - "time" - - "github.com/grafana/authlib/claims" -) - -var _ claims.IdentityClaims = &IDClaimsWrapper{} -var _ claims.AccessClaims = &IDClaimsWrapper{} - -type IDClaimsWrapper struct { - Source Requester -} - -func (i *IDClaimsWrapper) IsNil() bool { - return i.Source.IsNil() -} - -// GetAuthenticatedBy implements claims.IdentityClaims. -func (i *IDClaimsWrapper) AuthenticatedBy() string { - return i.Source.GetAuthenticatedBy() -} - -// GetDisplayName implements claims.IdentityClaims. -func (i *IDClaimsWrapper) DisplayName() string { - return i.Source.GetName() -} - -// GetEmail implements claims.IdentityClaims. -func (i *IDClaimsWrapper) Email() string { - return i.Source.GetEmail() -} - -// GetEmailVerified implements claims.IdentityClaims. -func (i *IDClaimsWrapper) EmailVerified() bool { - return i.Source.IsEmailVerified() -} - -// GetIdentityType implements claims.IdentityClaims. -func (i *IDClaimsWrapper) IdentityType() claims.IdentityType { - return i.Source.GetIdentityType() -} - -// GetRawUID implements claims.IdentityClaims. -func (i *IDClaimsWrapper) Identifier() string { - return i.Source.GetRawIdentifier() -} - -// GetUsername implements claims.IdentityClaims. -func (i *IDClaimsWrapper) Username() string { - return i.Source.GetLogin() -} - -// GetAudience implements claims.AccessClaims. -func (i *IDClaimsWrapper) Audience() []string { - return []string{fmt.Sprintf("org:%d", i.Source.GetOrgID())} -} - -// GetDelegatedPermissions implements claims.AccessClaims. -func (i *IDClaimsWrapper) DelegatedPermissions() []string { - return []string{} -} - -// GetExpiry implements claims.AccessClaims. -func (i *IDClaimsWrapper) Expiry() *time.Time { - return nil -} - -// GetIssuedAt implements claims.AccessClaims. -func (i *IDClaimsWrapper) IssuedAt() *time.Time { - return nil -} - -// GetIssuer implements claims.AccessClaims. -func (i *IDClaimsWrapper) Issuer() string { - return "" -} - -// GetJTI implements claims.AccessClaims. -func (i *IDClaimsWrapper) JTI() string { - return "" -} - -// GetNamespace implements claims.AccessClaims. -func (i *IDClaimsWrapper) Namespace() string { - return i.Source.GetNamespace() -} - -// GetNotBefore implements claims.AccessClaims. -func (i *IDClaimsWrapper) NotBefore() *time.Time { - return nil -} - -// GetPermissions implements claims.AccessClaims. -func (i *IDClaimsWrapper) Permissions() []string { - return []string{} -} - -// GetScopes implements claims.AccessClaims. -func (i *IDClaimsWrapper) Scopes() []string { - return []string{} -} - -// GetSubject implements claims.AccessClaims. -func (i *IDClaimsWrapper) Subject() string { - return i.Source.GetID() -} diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod index 1440a9eba93..6c9d89b0277 100644 --- a/pkg/apiserver/go.mod +++ b/pkg/apiserver/go.mod @@ -4,10 +4,10 @@ go 1.23.1 require ( github.com/google/go-cmp v0.6.0 - github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d + github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240701135906-559738ce6ae1 github.com/prometheus/client_golang v1.20.5 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 go.opentelemetry.io/contrib/propagators/jaeger v1.32.0 go.opentelemetry.io/otel v1.32.0 go.opentelemetry.io/otel/trace v1.32.0 @@ -79,7 +79,7 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect golang.org/x/net v0.31.0 // indirect - golang.org/x/oauth2 v0.23.0 // indirect + golang.org/x/oauth2 v0.24.0 // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/term v0.26.0 // indirect golang.org/x/text v0.20.0 // indirect diff --git a/pkg/apiserver/go.sum b/pkg/apiserver/go.sum index c3b7855d3a0..a9488469235 100644 --- a/pkg/apiserver/go.sum +++ b/pkg/apiserver/go.sum @@ -78,8 +78,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d h1:7nZfaXdC4Xc2ocMz5/Bx/3EsaEO34KsmA2RRcuogLnc= -github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A= +github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk= +github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240701135906-559738ce6ae1 h1:ItDcDxUjVLPKja+hogpqgW/kj8LxUL2qscelXIsN1Bs= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240701135906-559738ce6ae1/go.mod h1:DkxMin+qOh1Fgkxfbt+CUfBqqsCQJMG9op8Os/irBPA= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= @@ -162,8 +162,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -244,8 +244,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/build/go.mod b/pkg/build/go.mod index 25b27e3e918..cca4e0474e4 100644 --- a/pkg/build/go.mod +++ b/pkg/build/go.mod @@ -28,7 +28,7 @@ require ( github.com/google/uuid v1.6.0 // indirect; @grafana/grafana-backend-group github.com/googleapis/gax-go/v2 v2.13.0 // indirect; @grafana/grafana-backend-group github.com/jmespath/go-jmespath v0.4.0 // indirect; @grafana/grafana-backend-group - github.com/stretchr/testify v1.9.0 // @grafana/grafana-backend-group + github.com/stretchr/testify v1.10.0 // @grafana/grafana-backend-group github.com/urfave/cli v1.22.16 // @grafana/grafana-backend-group github.com/urfave/cli/v2 v2.27.1 // @grafana/grafana-backend-group go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0 // indirect; @grafana/plugins-platform-backend @@ -38,7 +38,7 @@ require ( golang.org/x/crypto v0.29.0 // indirect; @grafana/grafana-backend-group golang.org/x/mod v0.22.0 // @grafana/grafana-backend-group golang.org/x/net v0.31.0 // indirect; @grafana/oss-big-tent @grafana/partner-datasources - golang.org/x/oauth2 v0.23.0 // @grafana/identity-access-team + golang.org/x/oauth2 v0.24.0 // @grafana/identity-access-team golang.org/x/sync v0.9.0 // indirect; @grafana/alerting-backend golang.org/x/text v0.20.0 // indirect; @grafana/grafana-backend-group golang.org/x/time v0.6.0 // indirect; @grafana/grafana-backend-group diff --git a/pkg/build/go.sum b/pkg/build/go.sum index 42100a5bc72..b32d80c077c 100644 --- a/pkg/build/go.sum +++ b/pkg/build/go.sum @@ -212,8 +212,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= @@ -286,8 +287,8 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/promlib/go.mod b/pkg/promlib/go.mod index d0e71fe376a..9a0ee2b5e3b 100644 --- a/pkg/promlib/go.mod +++ b/pkg/promlib/go.mod @@ -4,13 +4,13 @@ go 1.23.1 require ( github.com/grafana/dskit v0.0.0-20240905221822-931a021fb06b - github.com/grafana/grafana-plugin-sdk-go v0.259.4 + github.com/grafana/grafana-plugin-sdk-go v0.260.1 github.com/json-iterator/go v1.1.12 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/prometheus/client_golang v1.20.5 github.com/prometheus/common v0.60.1 github.com/prometheus/prometheus v1.8.2-0.20221021121301-51a44e6657c3 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 go.opentelemetry.io/otel v1.32.0 go.opentelemetry.io/otel/trace v1.32.0 golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e diff --git a/pkg/promlib/go.sum b/pkg/promlib/go.sum index 440fc998496..c8f7bd62614 100644 --- a/pkg/promlib/go.sum +++ b/pkg/promlib/go.sum @@ -97,8 +97,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grafana/dskit v0.0.0-20240905221822-931a021fb06b h1:x2HCzk29I0o5pRPfqWP/qwhXaPGlcz8pohq5kO1NZoE= github.com/grafana/dskit v0.0.0-20240905221822-931a021fb06b/go.mod h1:SPLNCARd4xdjCkue0O6hvuoveuS1dGJjDnfxYe405YQ= -github.com/grafana/grafana-plugin-sdk-go v0.259.4 h1:Qt/Ic+7K7n7z4ncZs29q2OhOOPKWjE5+ms2ckeYlTo4= -github.com/grafana/grafana-plugin-sdk-go v0.259.4/go.mod h1:BS2B+lEHC4KtOViiS4C5x9SsR1ihPHAzesGOpaPYeCs= +github.com/grafana/grafana-plugin-sdk-go v0.260.1 h1:KzbooQP9mv/9CPsn+SoUwGuomA8oUxO0iuIq6Rg/ekE= +github.com/grafana/grafana-plugin-sdk-go v0.260.1/go.mod h1:JriieK5Oc5v120QKhMs/LO55N0P3YI2ttEiVT1wfYsw= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg= @@ -234,8 +234,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 h1:aVGB3YnaS/JNfOW3tiHIlmNmTDg618va+eT0mVomgyI= @@ -306,8 +307,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 4a47bcf3f5d..817f0e49323 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -132,7 +132,7 @@ type rowsWrapper struct { err error } -func (a *dashboardSqlAccess) Namespaces(ctx context.Context) ([]string, error) { +func (a *dashboardSqlAccess) GetResourceStats(ctx context.Context, minCount int) ([]resource.ResourceStats, error) { return nil, fmt.Errorf("not implemented") } @@ -382,10 +382,9 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das } var userID int64 - idClaims := user.GetIdentity() - if claims.IsIdentityType(idClaims.IdentityType(), claims.TypeUser) { + if claims.IsIdentityType(user.GetIdentityType(), claims.TypeUser) { var err error - userID, err = identity.UserIdentifier(idClaims.Subject()) + userID, err = identity.UserIdentifier(user.GetSubject()) if err != nil { return nil, false, err } diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 0d15aa5a439..15a5b3ed40c 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -12,7 +12,6 @@ import ( dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" dashboardv1alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1" dashboardv2alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" ) @@ -53,10 +52,6 @@ func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer { return nil // no authorizer } -func (b *DashboardsAPIBuilder) GetDesiredDualWriterMode(dualWrite bool, modeMap map[string]grafanarest.DualWriterMode) grafanarest.DualWriterMode { - return grafanarest.Mode0 -} - func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { if err := dashboardinternal.AddToScheme(scheme); err != nil { return err diff --git a/pkg/registry/apis/dashboard/v0alpha1/register.go b/pkg/registry/apis/dashboard/v0alpha1/register.go index 79570ae76fb..4c42f814303 100644 --- a/pkg/registry/apis/dashboard/v0alpha1/register.go +++ b/pkg/registry/apis/dashboard/v0alpha1/register.go @@ -16,7 +16,6 @@ import ( dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard" dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -98,11 +97,6 @@ func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer { return dashboard.GetAuthorizer(b.dashboardService, b.log) } -func (b *DashboardsAPIBuilder) GetDesiredDualWriterMode(dualWrite bool, modeMap map[string]grafanarest.DualWriterMode) grafanarest.DualWriterMode { - // Add required configuration support in order to enable other modes. For an example, see pkg/registry/apis/playlist/register.go - return grafanarest.Mode0 -} - func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { return dashboardv0alpha1.AddToScheme(scheme) } diff --git a/pkg/registry/apis/dashboard/v1alpha1/register.go b/pkg/registry/apis/dashboard/v1alpha1/register.go index 5023cee667a..d2acd73ff86 100644 --- a/pkg/registry/apis/dashboard/v1alpha1/register.go +++ b/pkg/registry/apis/dashboard/v1alpha1/register.go @@ -13,7 +13,6 @@ import ( dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard" dashboardv1alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -93,11 +92,6 @@ func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer { return dashboard.GetAuthorizer(b.dashboardService, b.log) } -func (b *DashboardsAPIBuilder) GetDesiredDualWriterMode(dualWrite bool, modeMap map[string]grafanarest.DualWriterMode) grafanarest.DualWriterMode { - // Add required configuration support in order to enable other modes. For an example, see pkg/registry/apis/playlist/register.go - return grafanarest.Mode0 -} - func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { return dashboardv1alpha1.AddToScheme(scheme) } diff --git a/pkg/registry/apis/dashboard/v2alpha1/register.go b/pkg/registry/apis/dashboard/v2alpha1/register.go index 4c0b8378138..eb5c5e4541a 100644 --- a/pkg/registry/apis/dashboard/v2alpha1/register.go +++ b/pkg/registry/apis/dashboard/v2alpha1/register.go @@ -13,7 +13,6 @@ import ( dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard" dashboardv2alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -93,11 +92,6 @@ func (b *DashboardsAPIBuilder) GetAuthorizer() authorizer.Authorizer { return dashboard.GetAuthorizer(b.dashboardService, b.log) } -func (b *DashboardsAPIBuilder) GetDesiredDualWriterMode(dualWrite bool, modeMap map[string]grafanarest.DualWriterMode) grafanarest.DualWriterMode { - // Add required configuration support in order to enable other modes. For an example, see pkg/registry/apis/playlist/register.go - return grafanarest.Mode0 -} - func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { return dashboardv2alpha1.AddToScheme(scheme) } diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index 2d0c2520884..e76a51801f4 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -89,7 +89,7 @@ func UnstructuredToLegacyFolder(item unstructured.Unstructured, orgID int64) (*f if created != nil { // #TODO Fix this time format. The legacy time format seems to be along the lines of time.Now() // which includes a part that represents a fraction of a second. Format should be "2024-09-12T15:37:41.09466+02:00" - createdTime = created.Local() + createdTime = (*created).UTC() } f := &folder.Folder{ @@ -122,6 +122,10 @@ func UnstructuredToLegacyFolder(item unstructured.Unstructured, orgID int64) (*f // #TODO figure out about adding version, parents, orgID fields } +func LegacyFolderToUnstructured(v *folder.Folder, namespacer request.NamespaceMapper) (*v0alpha1.Folder, error) { + return convertToK8sResource(v, namespacer) +} + func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) (*v0alpha1.Folder, error) { f := &v0alpha1.Folder{ TypeMeta: v0alpha1.FolderResourceInfo.TypeMeta(), @@ -208,11 +212,8 @@ func getURL(meta utils.GrafanaMetaAccessor, title string) string { } func getCreated(meta utils.GrafanaMetaAccessor) (*time.Time, error) { - created, err := meta.GetRepositoryTimestamp() - if err != nil { - return nil, err - } - return created, nil + created := meta.GetCreationTimestamp().Time + return &created, nil } func GetParentTitles(fullPath string) ([]string, error) { diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index 7b8628ef9a1..b1fe170b3d1 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -3,6 +3,7 @@ package folders import ( "context" "errors" + "fmt" "slices" "github.com/prometheus/client_golang/prometheus" @@ -246,6 +247,15 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes, obj := a.GetObject() + f, ok := obj.(*v0alpha1.Folder) + if !ok { + return fmt.Errorf("obj is not v0alpha1.Folder") + } + + if f.Spec.Title == "" { + return dashboards.ErrFolderTitleEmpty + } + for i := 1; i <= folderValidationRules.maxDepth; i++ { parent := getParent(obj) if parent == "" { diff --git a/pkg/registry/apis/folders/register_test.go b/pkg/registry/apis/folders/register_test.go index 6113a60516d..b3ff66b2393 100644 --- a/pkg/registry/apis/folders/register_test.go +++ b/pkg/registry/apis/folders/register_test.go @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/authorization/authorizer" ) @@ -149,6 +148,42 @@ func TestFolderAPIBuilder_getAuthorizerFunc(t *testing.T) { allow: false, }, }, + { + name: "user with write permissions should be able to update a folder", + input: input{ + user: &user.SignedInUser{ + UserID: 1, + OrgID: orgID, + Name: "123", + Permissions: map[int64]map[string][]string{ + orgID: {dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersAll}}, + }, + }, + verb: string(utils.VerbUpdate), + }, + expect: expect{ + eval: "folders:write", + allow: true, + }, + }, + { + name: "user without write permissions should NOT be able to update a folder", + input: input{ + user: &user.SignedInUser{ + UserID: 1, + OrgID: orgID, + Name: "123", + Permissions: map[int64]map[string][]string{ + orgID: {}, + }, + }, + verb: string(utils.VerbUpdate), + }, + expect: expect{ + eval: "folders:write", + allow: false, + }, + }, } b := &FolderAPIBuilder{ @@ -177,9 +212,19 @@ func TestFolderAPIBuilder_getAuthorizerFunc(t *testing.T) { func TestFolderAPIBuilder_Validate(t *testing.T) { type input struct { - obj *unstructured.Unstructured - name string + obj *v0alpha1.Folder + annotations map[string]string + name string } + + circularObj := &v0alpha1.Folder{ + Spec: v0alpha1.Spec{ + Title: "foo", + }, + } + circularObj.Name = "valid-name" + circularObj.Annotations = map[string]string{"grafana.app/folder": "valid-name"} + tests := []struct { name string input input @@ -189,9 +234,9 @@ func TestFolderAPIBuilder_Validate(t *testing.T) { { name: "should return error when name is invalid", input: input{ - obj: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "meta": map[string]interface{}{"name": folderValidationRules.invalidNames[0]}, + obj: &v0alpha1.Folder{ + Spec: v0alpha1.Spec{ + Title: "foo", }, }, name: folderValidationRules.invalidNames[0], @@ -201,9 +246,9 @@ func TestFolderAPIBuilder_Validate(t *testing.T) { { name: "should return no error if every validation passes", input: input{ - obj: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "meta": map[string]interface{}{"name": "valid-name"}, + obj: &v0alpha1.Folder{ + Spec: v0alpha1.Spec{ + Title: "foo", }, }, name: "valid-name", @@ -212,23 +257,33 @@ func TestFolderAPIBuilder_Validate(t *testing.T) { { name: "should return error when creating a nested folder higher than max depth", input: input{ - obj: &unstructured.Unstructured{ - Object: map[string]any{ - "metadata": map[string]any{"name": "valid-name", "annotations": map[string]any{"grafana.app/folder": "valid-name"}}, + obj: &v0alpha1.Folder{ + Spec: v0alpha1.Spec{ + Title: "foo", }, }, - name: "valid-name", + annotations: map[string]string{"grafana.app/folder": "valid-name"}, + name: "valid-name", }, setupFn: func(m *mock.Mock) { m.On("Get", mock.Anything, "valid-name", mock.Anything).Return( - &unstructured.Unstructured{ - Object: map[string]any{ - "metadata": map[string]any{"name": "valid-name", "annotations": map[string]any{"grafana.app/folder": "valid-name"}}, - }, - }, nil) + circularObj, + nil) }, err: folder.ErrMaximumDepthReached, }, + { + name: "should return error when title is empty", + input: input{ + obj: &v0alpha1.Folder{ + Spec: v0alpha1.Spec{ + Title: "", + }, + }, + name: "foo", + }, + err: dashboards.ErrFolderTitleEmpty, + }, } s := (grafanarest.Storage)(nil) @@ -246,6 +301,9 @@ func TestFolderAPIBuilder_Validate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + tt.input.obj.Name = tt.input.name + tt.input.obj.Annotations = tt.input.annotations + if tt.setupFn != nil { tt.setupFn(m) } @@ -264,7 +322,9 @@ func TestFolderAPIBuilder_Validate(t *testing.T) { &user.SignedInUser{}, ), nil) - if tt.err != nil { + if tt.err == nil { + require.NoError(t, err) + } else { require.ErrorIs(t, err, tt.err) return } diff --git a/pkg/registry/apis/peakq/register.go b/pkg/registry/apis/peakq/register.go index d77305389a8..7edcb93bd7f 100644 --- a/pkg/registry/apis/peakq/register.go +++ b/pkg/registry/apis/peakq/register.go @@ -35,7 +35,7 @@ func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration bui return nil // skip registration unless explicitly added (or all experimental are added) } builder := NewPeakQAPIBuilder() - apiregistration.RegisterAPI(NewPeakQAPIBuilder()) + apiregistration.RegisterAPI(builder) return builder } diff --git a/pkg/registry/apis/query/register.go b/pkg/registry/apis/query/register.go index 2354bede53a..fdbcb9c119b 100644 --- a/pkg/registry/apis/query/register.go +++ b/pkg/registry/apis/query/register.go @@ -98,8 +98,9 @@ func RegisterAPIService(features featuremgmt.FeatureToggles, tracer tracing.Tracer, legacy service.LegacyDataSourceLookup, ) (*QueryAPIBuilder, error) { - if !(features.IsEnabledGlobally(featuremgmt.FlagQueryService) || - features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs)) { + if !featuremgmt.AnyEnabled(features, + featuremgmt.FlagQueryService, + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { return nil, nil // skip registration unless explicitly added (or all experimental are added) } diff --git a/pkg/registry/apis/service/register.go b/pkg/registry/apis/service/register.go index 52c26bf3ce0..24827a36ace 100644 --- a/pkg/registry/apis/service/register.go +++ b/pkg/registry/apis/service/register.go @@ -31,7 +31,7 @@ func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration bui } builder := NewServiceAPIBuilder() - apiregistration.RegisterAPI(NewServiceAPIBuilder()) + apiregistration.RegisterAPI(builder) return builder } diff --git a/pkg/semconv/go.mod b/pkg/semconv/go.mod index e950b719c3c..16b86923a86 100644 --- a/pkg/semconv/go.mod +++ b/pkg/semconv/go.mod @@ -7,4 +7,5 @@ require go.opentelemetry.io/otel v1.32.0 require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/testify v1.10.0 // indirect ) diff --git a/pkg/semconv/go.sum b/pkg/semconv/go.sum index 3b7fc928686..e4df191d3fa 100644 --- a/pkg/semconv/go.sum +++ b/pkg/semconv/go.sum @@ -4,8 +4,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/services/apiserver/standalone/options/logging.go b/pkg/services/apiserver/standalone/options/logging.go deleted file mode 100644 index 4f6bf0ebb4c..00000000000 --- a/pkg/services/apiserver/standalone/options/logging.go +++ /dev/null @@ -1,41 +0,0 @@ -package options - -import ( - "time" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/setting" - "github.com/spf13/pflag" - genericapiserver "k8s.io/apiserver/pkg/server" -) - -type LoggingOptions struct { - logger log.Logger - Level string -} - -func NewLoggingOptions(logger log.Logger) *LoggingOptions { - return &LoggingOptions{ - logger: logger, - } -} - -func (o *LoggingOptions) AddFlags(fs *pflag.FlagSet) { - fs.StringVar(&o.Level, "grafana.log.level", "debug", "Log level, debug, info, warn, error.") -} - -func (o *LoggingOptions) Validate() []error { - return nil -} - -func (o *LoggingOptions) ApplyTo(c *genericapiserver.RecommendedConfig) error { - err := log.SetupConsoleLogger(o.Level) - if err != nil { - return err - } - - o.logger.Info("Starting grafana-apiserver", "version", setting.BuildVersion, "commit", setting.BuildCommit, "branch", setting.BuildBranch, "compiled", time.Unix(setting.BuildStamp, 0)) - o.logger.Debug("Console logging initialized", "logLevel", o.Level) - - return nil -} diff --git a/pkg/services/apiserver/standalone/options/metrics.go b/pkg/services/apiserver/standalone/options/metrics.go deleted file mode 100644 index 11f6bdc9391..00000000000 --- a/pkg/services/apiserver/standalone/options/metrics.go +++ /dev/null @@ -1,42 +0,0 @@ -package options - -import ( - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/setting" - "github.com/prometheus/client_golang/prometheus" - "github.com/spf13/pflag" - genericapiserver "k8s.io/apiserver/pkg/server" -) - -type MetricsOptions struct { - logger log.Logger - Enabled bool - MetricsRegisterer prometheus.Registerer -} - -func NewMetricsOptions(logger log.Logger) *MetricsOptions { - return &MetricsOptions{ - logger: logger, - } -} - -func (o *MetricsOptions) AddFlags(fs *pflag.FlagSet) { - fs.BoolVar(&o.Enabled, "grafana.metrics.enable", false, "Enable metrics and Prometheus /metrics endpoint.") -} - -func (o *MetricsOptions) Validate() []error { - return nil -} - -func (o *MetricsOptions) ApplyTo(c *genericapiserver.RecommendedConfig) error { - c.EnableMetrics = o.Enabled - o.MetricsRegisterer = metrics.ProvideRegisterer() - metrics.SetBuildInformation(o.MetricsRegisterer, setting.BuildVersion, setting.BuildCommit, setting.BuildBranch, setting.BuildStamp) - - if o.Enabled { - o.logger.Debug("Metrics enabled") - } - - return nil -} diff --git a/pkg/services/apiserver/standalone/options/options.go b/pkg/services/apiserver/standalone/options/options.go deleted file mode 100644 index 1288550aea1..00000000000 --- a/pkg/services/apiserver/standalone/options/options.go +++ /dev/null @@ -1,216 +0,0 @@ -package options - -import ( - "fmt" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/apiserver/options" - "github.com/spf13/pflag" - "k8s.io/apimachinery/pkg/runtime" - genericapiserver "k8s.io/apiserver/pkg/server" - genericoptions "k8s.io/apiserver/pkg/server/options" -) - -type Options struct { - LoggingOptions *LoggingOptions - ExtraOptions *options.ExtraOptions - RecommendedOptions *genericoptions.RecommendedOptions - TracingOptions *TracingOptions - MetricsOptions *MetricsOptions - ProfilingOptions *ProfilingOptions - ServerRunOptions *genericoptions.ServerRunOptions - StorageOptions *options.StorageOptions -} - -func New(logger log.Logger, codec runtime.Codec) *Options { - return &Options{ - LoggingOptions: NewLoggingOptions(logger), - ExtraOptions: options.NewExtraOptions(), - RecommendedOptions: options.NewRecommendedOptions(codec), - TracingOptions: NewTracingOptions(logger), - MetricsOptions: NewMetricsOptions(logger), - ProfilingOptions: NewProfilingOptions(logger), - ServerRunOptions: genericoptions.NewServerRunOptions(), - StorageOptions: options.NewStorageOptions(), - } -} - -func (o *Options) AddFlags(fs *pflag.FlagSet) { - o.LoggingOptions.AddFlags(fs) - o.ExtraOptions.AddFlags(fs) - o.RecommendedOptions.AddFlags(fs) - o.TracingOptions.AddFlags(fs) - o.MetricsOptions.AddFlags(fs) - o.ProfilingOptions.AddFlags(fs) - o.ServerRunOptions.AddUniversalFlags(fs) - o.internalFlags(fs) -} - -func (o *Options) Validate() []error { - if errs := o.LoggingOptions.Validate(); len(errs) != 0 { - return errs - } - - if errs := o.ExtraOptions.Validate(); len(errs) != 0 { - return errs - } - - if errs := o.TracingOptions.Validate(); len(errs) != 0 { - return errs - } - - if errs := o.MetricsOptions.Validate(); len(errs) != 0 { - return errs - } - - if errs := o.ProfilingOptions.Validate(); len(errs) != 0 { - return errs - } - - if errs := o.ServerRunOptions.Validate(); len(errs) != 0 { - return errs - } - - if errs := o.internalFlagsValidate(); len(errs) != 0 { - return errs - } - - // NOTE: we don't call validate on the top level recommended options as it doesn't like skipping etcd-servers - // the function is left here for troubleshooting any other config issues - // errors = append(errors, o.RecommendedOptions.Validate()...) - - if errs := o.RecommendedOptions.SecureServing.Validate(); len(errs) != 0 { - return errs - } - - if o.ExtraOptions.DevMode { - // NOTE: Only consider authn for dev mode - resolves the failure due to missing extension apiserver auth-config - // in parent k8s - if errs := o.RecommendedOptions.Authentication.Validate(); len(errs) != 0 { - return errs - } - } - - return nil -} - -// A copy of ApplyTo in recommended.go, but for >= 0.28, server pkg in apiserver does a bit extra causing -// a panic when CoreAPI is set to nil -func (o *Options) ModifiedApplyTo(config *genericapiserver.RecommendedConfig) error { - if err := o.RecommendedOptions.Etcd.ApplyTo(&config.Config); err != nil { - return err - } - if err := o.RecommendedOptions.EgressSelector.ApplyTo(&config.Config); err != nil { - return err - } - if err := o.RecommendedOptions.Traces.ApplyTo(config.Config.EgressSelector, &config.Config); err != nil { - return err - } - if err := o.RecommendedOptions.SecureServing.ApplyTo(&config.Config.SecureServing, &config.Config.LoopbackClientConfig); err != nil { - return err - } - if err := o.RecommendedOptions.Authentication.ApplyTo(&config.Config.Authentication, config.SecureServing, config.OpenAPIConfig); err != nil { - return err - } - if err := o.RecommendedOptions.Authorization.ApplyTo(&config.Config.Authorization); err != nil { - return err - } - if err := o.RecommendedOptions.Audit.ApplyTo(&config.Config); err != nil { - return err - } - - // TODO: determine whether we need flow control (API priority and fairness) - // We can't assume that a shared informers config was provided in standalone mode and will need a guard - // when enabling below - /* kubeClient, err := kubernetes.NewForConfig(config.ClientConfig) - if err != nil { - return err - } - - if err := o.RecommendedOptions.Features.ApplyTo(&config.Config, kubeClient, config.SharedInformerFactory); err != nil { - return err - } */ - - if err := o.RecommendedOptions.CoreAPI.ApplyTo(config); err != nil { - return err - } - - _, err := o.RecommendedOptions.ExtraAdmissionInitializers(config) - if err != nil { - return err - } - - if err := o.ServerRunOptions.ApplyTo(&config.Config); err != nil { - return err - } - - return nil -} - -func (o *Options) ApplyTo(serverConfig *genericapiserver.RecommendedConfig) error { - if o.LoggingOptions != nil { - if err := o.LoggingOptions.ApplyTo(serverConfig); err != nil { - return err - } - } - - if o.ExtraOptions != nil { - if err := o.ExtraOptions.ApplyTo(serverConfig); err != nil { - return err - } - } - - if o.RecommendedOptions.CoreAPI == nil { - if err := o.ModifiedApplyTo(serverConfig); err != nil { - return err - } - } else { - if err := o.RecommendedOptions.ApplyTo(serverConfig); err != nil { - return err - } - } - - if o.TracingOptions != nil { - if err := o.TracingOptions.ApplyTo(serverConfig); err != nil { - return err - } - } - - if o.MetricsOptions != nil { - if err := o.MetricsOptions.ApplyTo(serverConfig); err != nil { - return err - } - } - - if o.ProfilingOptions != nil { - if err := o.ProfilingOptions.ApplyTo(serverConfig); err != nil { - return err - } - } - - if o.ServerRunOptions != nil { - if err := o.ServerRunOptions.ApplyTo(&serverConfig.Config); err != nil { - return err - } - } - - return nil -} - -func (o *Options) internalFlags(fs *pflag.FlagSet) { - // We also want to be able to set the MaxRequestSize by using flags. This is - // usually not exposed by k8s. The value is already set to the upstream default - // at this stage, so we can use it as the default. - // Reference: https://github.com/kubernetes/kubernetes/blob/v1.31.1/staging/src/k8s.io/apiserver/pkg/server/config.go#L453 - fs.Int64Var(&o.ServerRunOptions.MaxRequestBodyBytes, "max-request-body-bytes", - o.ServerRunOptions.MaxRequestBodyBytes, ""+ - "Specifies the maximum allowable size for a request payload sent to the API server in bytes."+ - "The default is 3MB.") -} - -func (o *Options) internalFlagsValidate() []error { - if o.ServerRunOptions.MaxRequestBodyBytes < 0 { - return []error{fmt.Errorf("--max-request-body-bytes can not be a negative value")} - } - return nil -} diff --git a/pkg/services/apiserver/standalone/options/profiling.go b/pkg/services/apiserver/standalone/options/profiling.go deleted file mode 100644 index 073ef7d6e0f..00000000000 --- a/pkg/services/apiserver/standalone/options/profiling.go +++ /dev/null @@ -1,46 +0,0 @@ -package options - -import ( - "runtime" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/spf13/pflag" - genericapiserver "k8s.io/apiserver/pkg/server" -) - -type ProfilingOptions struct { - logger log.Logger - blockProfilingRate int - mutexProfilingRate int -} - -func NewProfilingOptions(logger log.Logger) *ProfilingOptions { - return &ProfilingOptions{ - logger: logger, - } -} - -func (o *ProfilingOptions) AddFlags(fs *pflag.FlagSet) { - fs.IntVar(&o.blockProfilingRate, "grafana.profiling.block-rate", 0, "Controls the fraction of goroutine blocking events that are reported in the blocking profile. The profiler aims to sample an average of one blocking event per rate nanoseconds spent blocked. To turn off profiling entirely, use 0.") - fs.IntVar(&o.mutexProfilingRate, "grafana.profiling.mutex-rate", 0, "Controls the fraction of mutex contention events that are reported in the mutex profile. On average 1/rate events are reported. To turn off mutex profiling entirely, use 0.") -} - -func (o *ProfilingOptions) Validate() []error { - return nil -} - -func (o *ProfilingOptions) ApplyTo(config *genericapiserver.RecommendedConfig) error { - if !config.EnableProfiling { - return nil - } - - // We bring our own block/mutex profiling configuration - config.EnableContentionProfiling = false - - runtime.SetBlockProfileRate(o.blockProfilingRate) - runtime.SetMutexProfileFraction(o.mutexProfilingRate) - - o.logger.Info("Profiling enabled", "blockProfileRate", o.blockProfilingRate, "mutexProfileRate", o.mutexProfilingRate) - - return nil -} diff --git a/pkg/services/apiserver/standalone/options/tracing.go b/pkg/services/apiserver/standalone/options/tracing.go deleted file mode 100644 index 058f3157879..00000000000 --- a/pkg/services/apiserver/standalone/options/tracing.go +++ /dev/null @@ -1,137 +0,0 @@ -package options - -import ( - "context" - "errors" - "fmt" - "net/url" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/setting" - "github.com/spf13/pflag" - "go.opentelemetry.io/otel/attribute" - genericfeatures "k8s.io/apiserver/pkg/features" - genericapiserver "k8s.io/apiserver/pkg/server" - utilfeature "k8s.io/apiserver/pkg/util/feature" - k8stracing "k8s.io/component-base/tracing" -) - -type TracingOptions struct { - logger log.Logger - - JaegerAddress string - JaegerPropagation string - - OTLPAddress string - OTLPPropagation string - - ServiceName string - Tags map[string]string - - SamplerType string - SamplerParam float64 - SamplingServiceURL string - - TracingService *tracing.TracingService -} - -func NewTracingOptions(logger log.Logger) *TracingOptions { - return &TracingOptions{ - logger: logger, - Tags: map[string]string{}, - } -} - -func (o *TracingOptions) AddFlags(fs *pflag.FlagSet) { - fs.StringVar(&o.JaegerAddress, "grafana.tracing.jaeger.address", "", "Tracing Jaeger exporter destination, e.g. http://localhost:14268/api/traces. This enabled the Jaeger export and takes presedence over grafana.tracing.otlp.") - fs.StringVar(&o.JaegerPropagation, "grafana.tracing.jaeger.propagation", "jaeger", "Tracing Jaeger propagation specifies the text map propagation format, w3c or jaeger.") - fs.StringVar(&o.OTLPAddress, "grafana.tracing.otlp.address", "", "Tracing OTLP exporter destination, e.g. localhost:4317.") - fs.StringVar(&o.OTLPPropagation, "grafana.tracing.otlp.propagation", "w3c", "Tracing OTLP propagation specifies the text map propagation format, w3c or jaeger.") - fs.StringVar(&o.ServiceName, "grafana.tracing.service-name", "grafana-apiserver", "Override the default service name, grafana-apiserver.") - fs.StringToStringVar(&o.Tags, "grafana.tracing.tag", map[string]string{}, "Tracing server tag in 'key=value' format. Specify multiple times to add many.") - fs.StringVar(&o.SamplerType, "grafana.tracing.sampler-type", "const", "Tracing sampler type specifies the type of the sampler: const, probabilistic, rateLimiting, or remote.") - fs.Float64Var(&o.SamplerParam, "grafana.tracing.sampler-param", 0, "Tracing sampler configuration parameter. For 'const' sampler, 0 or 1 for always false/true respectively. For 'rateLimiting' sampler, the number of spans per second. For 'remote' sampler, param is the same as for 'probabilistic' and indicates the initial sampling rate before the actual one is received from the sampling service.") - fs.StringVar(&o.SamplingServiceURL, "grafana.tracing.sampling-service", "", "Tracing server sampling service URL (used for both Jaeger and OTLP) if grafana.tracing.sampler-type=remote.") -} - -func (o *TracingOptions) Validate() []error { - errs := []error{} - - if o.JaegerAddress != "" { - if _, err := url.Parse(o.JaegerAddress); err != nil { - errs = append(errs, fmt.Errorf("failed to parse tracing.jaeger.address: %w", err)) - } - } - - if o.SamplingServiceURL != "" { - if _, err := url.Parse(o.SamplingServiceURL); err != nil { - errs = append(errs, fmt.Errorf("failed to parse tracing.sampling-service: %w", err)) - } - } - - if o.ServiceName == "" { - errs = append(errs, errors.New("grafana.tracing.service-name cannot be empty")) - } - - return errs -} - -func (o *TracingOptions) ApplyTo(config *genericapiserver.RecommendedConfig) error { - if err := utilfeature.DefaultMutableFeatureGate.SetFromMap(map[string]bool{ - string(genericfeatures.APIServerTracing): false, - }); err != nil { - return err - } - - tracingCfg := tracing.NewEmptyTracingConfig() - var err error - - if o.OTLPAddress != "" { - tracingCfg, err = tracing.NewOTLPTracingConfig(o.OTLPAddress, o.OTLPPropagation) - } - - if o.JaegerAddress != "" { - tracingCfg, err = tracing.NewJaegerTracingConfig(o.JaegerAddress, o.JaegerPropagation) - } - - if err != nil { - return err - } - - tracingCfg.ServiceName = o.ServiceName - tracingCfg.ServiceVersion = setting.BuildVersion - - for k, v := range o.Tags { - tracingCfg.CustomAttribs = append(tracingCfg.CustomAttribs, attribute.String(k, v)) - } - - tracingCfg.Sampler = o.SamplerType - tracingCfg.SamplerParam = o.SamplerParam - tracingCfg.SamplerRemoteURL = o.SamplingServiceURL - tracingCfg.ProfilingIntegration = true - - ts, err := tracing.ProvideService(tracingCfg) - if err != nil { - return err - } - - o.TracingService = ts - config.TracerProvider = ts.GetTracerProvider() - - if config.LoopbackClientConfig != nil { - config.LoopbackClientConfig.Wrap(k8stracing.WrapperFor(config.TracerProvider)) - } - - config.AddPostStartHookOrDie("grafana-tracing-service", func(hookCtx genericapiserver.PostStartHookContext) error { - go func() { - if err := ts.Run(hookCtx.Context); err != nil && !errors.Is(err, context.Canceled) { - o.logger.Error("failed to shutdown tracing service", "error", err) - } - }() - - return nil - }) - - return nil -} diff --git a/pkg/services/auth/idimpl/service.go b/pkg/services/auth/idimpl/service.go index 8c11374d910..f584b226952 100644 --- a/pkg/services/auth/idimpl/service.go +++ b/pkg/services/auth/idimpl/service.go @@ -86,7 +86,7 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri now := time.Now() idClaims := &auth.IDClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Issuer: s.cfg.AppURL, Audience: getAudience(id.GetOrgID()), Subject: id.GetID(), @@ -102,7 +102,7 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri if id.IsIdentityType(claims.TypeUser) { idClaims.Rest.Email = id.GetEmail() - idClaims.Rest.EmailVerified = id.IsEmailVerified() + idClaims.Rest.EmailVerified = id.GetEmailVerified() idClaims.Rest.AuthenticatedBy = id.GetAuthenticatedBy() idClaims.Rest.Username = id.GetLogin() idClaims.Rest.DisplayName = id.GetName() diff --git a/pkg/services/auth/idtest/internal_token.go b/pkg/services/auth/idtest/internal_token.go deleted file mode 100644 index 2f1b96bcb38..00000000000 --- a/pkg/services/auth/idtest/internal_token.go +++ /dev/null @@ -1,54 +0,0 @@ -package idtest - -import ( - "time" - - "github.com/go-jose/go-jose/v3" - "github.com/go-jose/go-jose/v3/jwt" - authnlib "github.com/grafana/authlib/authn" - "github.com/grafana/authlib/claims" - - "github.com/grafana/grafana/pkg/services/auth" -) - -func CreateInternalToken(authInfo claims.AuthInfo, secret []byte) (string, *authnlib.Claims[authnlib.IDTokenClaims], error) { - signerOpts := jose.SignerOptions{} - signerOpts.WithType("jwt") // Should be uppercase, but this is what authlib expects - signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: secret}, &signerOpts) - if err != nil { - return "", nil, err - } - - identity := authInfo.GetIdentity() - now := time.Now() - tokenTTL := 10 * time.Minute - idClaims := &auth.IDClaims{ - Claims: &jwt.Claims{ - Audience: identity.Audience(), - Subject: identity.Subject(), - Expiry: jwt.NewNumericDate(now.Add(tokenTTL)), - IssuedAt: jwt.NewNumericDate(now), - }, - Rest: authnlib.IDTokenClaims{ - Namespace: identity.Namespace(), - Identifier: identity.Identifier(), - Type: identity.IdentityType(), - }, - } - - if claims.IsIdentityType(identity.IdentityType(), claims.TypeUser) { - idClaims.Rest.Email = identity.Email() - idClaims.Rest.EmailVerified = identity.EmailVerified() - idClaims.Rest.AuthenticatedBy = identity.AuthenticatedBy() - idClaims.Rest.Username = identity.Username() - idClaims.Rest.DisplayName = identity.DisplayName() - } - - builder := jwt.Signed(signer).Claims(&idClaims.Rest).Claims(idClaims.Claims) - token, err := builder.CompactSerialize() - if err != nil { - return "", nil, err - } - - return token, idClaims, nil -} diff --git a/pkg/services/authn/clients/ext_jwt.go b/pkg/services/authn/clients/ext_jwt.go index cd918d32f85..571f9ff5c35 100644 --- a/pkg/services/authn/clients/ext_jwt.go +++ b/pkg/services/authn/clients/ext_jwt.go @@ -100,12 +100,12 @@ func (s *ExtendedJWT) authenticateAsUser( accessTokenClaims authlib.Claims[authlib.AccessTokenClaims], ) (*authn.Identity, error) { // Only allow id tokens signed for namespace configured for this instance. - if allowedNamespace := s.namespaceMapper(s.cfg.DefaultOrgID()); !claims.NamespaceMatches(authlib.NewIdentityClaims(idTokenClaims), allowedNamespace) { + if allowedNamespace := s.namespaceMapper(s.cfg.DefaultOrgID()); !claims.NamespaceMatches(idTokenClaims.Rest.Namespace, allowedNamespace) { return nil, errExtJWTDisallowedNamespaceClaim.Errorf("unexpected id token namespace: %s", idTokenClaims.Rest.Namespace) } // Allow access tokens with either the same namespace as the validated id token namespace or wildcard (`*`). - if !claims.NamespaceMatches(authlib.NewAccessClaims(accessTokenClaims), idTokenClaims.Rest.Namespace) { + if !claims.NamespaceMatches(accessTokenClaims.Rest.Namespace, idTokenClaims.Rest.Namespace) { return nil, errExtJWTMisMatchedNamespaceClaims.Errorf("unexpected access token namespace: %s", accessTokenClaims.Rest.Namespace) } @@ -155,7 +155,7 @@ func (s *ExtendedJWT) authenticateAsUser( func (s *ExtendedJWT) authenticateAsService(accessTokenClaims authlib.Claims[authlib.AccessTokenClaims]) (*authn.Identity, error) { // Allow access tokens with that has a wildcard namespace or a namespace matching this instance. - if allowedNamespace := s.namespaceMapper(s.cfg.DefaultOrgID()); !claims.NamespaceMatches(authlib.NewAccessClaims(accessTokenClaims), allowedNamespace) { + if allowedNamespace := s.namespaceMapper(s.cfg.DefaultOrgID()); !claims.NamespaceMatches(accessTokenClaims.Rest.Namespace, allowedNamespace) { return nil, errExtJWTDisallowedNamespaceClaim.Errorf("unexpected access token namespace: %s", accessTokenClaims.Rest.Namespace) } diff --git a/pkg/services/authn/clients/ext_jwt_test.go b/pkg/services/authn/clients/ext_jwt_test.go index 10bd830d7f6..f09cd95ed29 100644 --- a/pkg/services/authn/clients/ext_jwt_test.go +++ b/pkg/services/authn/clients/ext_jwt_test.go @@ -27,7 +27,7 @@ type ( var ( validAccessTokenClaims = accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Subject: "access-policy:this-uid", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), @@ -40,7 +40,7 @@ var ( }, } validIDTokenClaims = idTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Subject: "user:2", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), @@ -51,7 +51,7 @@ var ( }, } validIDTokenClaimsWithStackSet = idTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Subject: "user:2", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), @@ -62,7 +62,7 @@ var ( }, } validIDTokenClaimsWithDeprecatedStackClaimSet = idTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Subject: "user:2", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), @@ -73,7 +73,7 @@ var ( }, } validAccessTokenClaimsWildcard = accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Subject: "access-policy:this-uid", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), @@ -83,7 +83,7 @@ var ( }, } validAccessTokenClaimsWithStackSet = accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Subject: "access-policy:this-uid", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), @@ -93,7 +93,7 @@ var ( }, } validAccessTokenClaimsWithDeprecatedStackClaimSet = accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Subject: "access-policy:this-uid", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), @@ -103,7 +103,7 @@ var ( }, } invalidNamespaceIDTokenClaims = idTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Subject: "user:2", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), @@ -114,7 +114,7 @@ var ( }, } invalidSubjectIDTokenClaims = idTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Subject: "service-account:2", Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)), IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)), @@ -459,7 +459,7 @@ func TestExtendedJWT_Authenticate(t *testing.T) { { name: "should return error when the subject is not an access-policy", accessToken: &accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Issuer: "http://localhost:3000", Subject: "user:2", Audience: jwt.Audience{"http://localhost:3000"}, @@ -523,7 +523,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { { name: "missing iss", payload: &accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Subject: "access-policy:this-uid", Audience: jwt.Audience{"http://localhost:3000"}, ID: "1234567890", @@ -538,7 +538,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { { name: "missing expiry", payload: &accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", Audience: jwt.Audience{"http://localhost:3000"}, @@ -553,7 +553,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { { name: "expired token", payload: &accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", Audience: jwt.Audience{"http://localhost:3000"}, @@ -569,7 +569,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { { name: "missing aud", payload: &accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", ID: "1234567890", @@ -584,7 +584,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { { name: "wrong aud", payload: &accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", Audience: jwt.Audience{"http://some-other-host:3000"}, @@ -605,7 +605,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { { name: "missing sub", payload: &accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Issuer: "http://localhost:3000", Audience: jwt.Audience{"http://localhost:3000"}, ID: "1234567890", @@ -620,7 +620,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { { name: "missing iat", payload: &accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", Audience: jwt.Audience{"http://localhost:3000"}, @@ -635,7 +635,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { { name: "iat later than current time", payload: &accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", Audience: jwt.Audience{"http://localhost:3000"}, @@ -651,7 +651,7 @@ func TestVerifyRFC9068TokenFailureScenarios(t *testing.T) { { name: "unsupported alg", payload: &accessTokenClaims{ - Claims: &jwt.Claims{ + Claims: jwt.Claims{ Issuer: "http://localhost:3000", Subject: "access-policy:this-uid", Audience: jwt.Audience{"http://localhost:3000"}, diff --git a/pkg/services/authn/identity.go b/pkg/services/authn/identity.go index 382530c9ca8..ca6391dcef2 100644 --- a/pkg/services/authn/identity.go +++ b/pkg/services/authn/identity.go @@ -74,32 +74,30 @@ type Identity struct { // Permissions is the list of permissions the entity has. Permissions map[int64]map[string][]string // IDToken is a signed token representing the identity that can be forwarded to plugins and external services. - IDToken string - IDTokenClaims *authn.Claims[authn.IDTokenClaims] + IDToken string + IDTokenClaims *authn.Claims[authn.IDTokenClaims] AccessTokenClaims *authn.Claims[authn.AccessTokenClaims] } -func (i *Identity) GetAccess() claims.AccessClaims { - if i.AccessTokenClaims != nil { - return authn.NewAccessClaims(*i.AccessTokenClaims) - } - return &identity.IDClaimsWrapper{Source: i} +func (i *Identity) GetID() string { + return i.GetSubject() } -func (i *Identity) GetIdentity() claims.IdentityClaims { - if i.IDTokenClaims != nil { - return authn.NewIdentityClaims(*i.IDTokenClaims) - } - return &identity.IDClaimsWrapper{Source: i} +func (i *Identity) GetInternalID() (int64, error) { + return identity.IntIdentifier(i.GetID()) +} + +func (i *Identity) GetUID() string { + return claims.NewTypeID(i.Type, i.UID) } func (i *Identity) GetRawIdentifier() string { return i.UID } -func (i *Identity) GetInternalID() (int64, error) { - return identity.IntIdentifier(i.GetID()) +func (i *Identity) GetIdentifier() string { + return i.UID } func (i *Identity) GetIdentityType() claims.IdentityType { @@ -110,6 +108,43 @@ func (i *Identity) IsIdentityType(expected ...claims.IdentityType) bool { return claims.IsIdentityType(i.GetIdentityType(), expected...) } +func (i *Identity) GetNamespace() string { + return i.Namespace +} + +func (i *Identity) GetSubject() string { + return claims.NewTypeID(i.Type, i.ID) +} + +func (i *Identity) GetAudience() []string { + if i.AccessTokenClaims != nil { + return i.AccessTokenClaims.Audience + } + return []string{} +} + +func (i *Identity) GetEmailVerified() bool { + return i.EmailVerified +} + +func (i *Identity) GetTokenPermissions() []string { + if i.AccessTokenClaims != nil { + return i.AccessTokenClaims.Rest.Permissions + } + return []string{} +} + +func (i *Identity) GetTokenDelegatedPermissions() []string { + if i.AccessTokenClaims != nil { + return i.AccessTokenClaims.Rest.DelegatedPermissions + } + return []string{} +} + +func (i *Identity) GetGroups() []string { + return []string{} +} + func (i *Identity) GetExtra() map[string][]string { extra := map[string][]string{} if i.IDToken != "" { @@ -121,10 +156,6 @@ func (i *Identity) GetExtra() map[string][]string { return extra } -func (i *Identity) GetGroups() []string { - return []string{} // teams? -} - func (i *Identity) GetName() string { if i.Name != "" { return i.Name @@ -135,14 +166,6 @@ func (i *Identity) GetName() string { return i.Email } -func (i *Identity) GetID() string { - return claims.NewTypeID(i.Type, i.ID) -} - -func (i *Identity) GetUID() string { - return claims.NewTypeID(i.Type, i.UID) -} - func (i *Identity) GetAuthID() string { return i.AuthID } @@ -166,10 +189,6 @@ func (i *Identity) GetEmail() string { return i.Email } -func (i *Identity) IsEmailVerified() bool { - return i.EmailVerified -} - func (i *Identity) GetIDToken() string { return i.IDToken } @@ -178,12 +197,12 @@ func (i *Identity) GetIsGrafanaAdmin() bool { return i.IsGrafanaAdmin != nil && *i.IsGrafanaAdmin } -func (i *Identity) GetLogin() string { +func (i *Identity) GetUsername() string { return i.Login } -func (i *Identity) GetNamespace() string { - return i.Namespace +func (i *Identity) GetLogin() string { + return i.Login } func (i *Identity) GetOrgID() int64 { @@ -263,24 +282,27 @@ func (i *Identity) IsNil() bool { // SignedInUser returns a SignedInUser from the identity. func (i *Identity) SignedInUser() *user.SignedInUser { u := &user.SignedInUser{ - OrgID: i.OrgID, - OrgName: i.OrgName, - OrgRole: i.GetOrgRole(), - Login: i.Login, - Name: i.Name, - Email: i.Email, - AuthID: i.AuthID, - AuthenticatedBy: i.AuthenticatedBy, - IsGrafanaAdmin: i.GetIsGrafanaAdmin(), - IsAnonymous: i.IsIdentityType(claims.TypeAnonymous), - IsDisabled: i.IsDisabled, - HelpFlags1: i.HelpFlags1, - LastSeenAt: i.LastSeenAt, - Teams: i.Teams, - Permissions: i.Permissions, - IDToken: i.IDToken, - FallbackType: i.Type, - Namespace: i.Namespace, + OrgID: i.OrgID, + OrgName: i.OrgName, + OrgRole: i.GetOrgRole(), + Login: i.Login, + Name: i.Name, + Email: i.Email, + EmailVerified: i.EmailVerified, + AuthID: i.AuthID, + AuthenticatedBy: i.AuthenticatedBy, + Namespace: i.Namespace, + IsGrafanaAdmin: i.GetIsGrafanaAdmin(), + IsAnonymous: i.IsIdentityType(claims.TypeAnonymous), + IsDisabled: i.IsDisabled, + HelpFlags1: i.HelpFlags1, + LastSeenAt: i.LastSeenAt, + Teams: i.Teams, + Permissions: i.Permissions, + IDToken: i.IDToken, + IDTokenClaims: i.IDTokenClaims, + AccessTokenClaims: i.AccessTokenClaims, + FallbackType: i.Type, } if i.IsIdentityType(claims.TypeAPIKey) { diff --git a/pkg/services/user/identity.go b/pkg/services/user/identity.go index 2ea72e1cb9a..34b9f5c378b 100644 --- a/pkg/services/user/identity.go +++ b/pkg/services/user/identity.go @@ -15,7 +15,7 @@ const ( GlobalOrgID = int64(0) ) -var _ identity.Requester = &SignedInUser{} +var _ identity.Requester = (*SignedInUser)(nil) type SignedInUser struct { UserID int64 `xorm:"user_id"` @@ -45,28 +45,28 @@ type SignedInUser struct { Permissions map[int64]map[string][]string `json:"-"` // IDToken is a signed token representing the identity that can be forwarded to plugins and external services. - IDToken string `json:"-" xorm:"-"` - IDTokenClaims *authnlib.Claims[authnlib.IDTokenClaims] `json:"-" xorm:"-"` + IDToken string `json:"-" xorm:"-"` + IDTokenClaims *authnlib.Claims[authnlib.IDTokenClaims] `json:"-" xorm:"-"` + AccessTokenClaims *authnlib.Claims[authnlib.AccessTokenClaims] `json:"-" xorm:"-"` // When other settings are not deterministic, this value is used FallbackType claims.IdentityType } -// Access implements claims.AuthInfo. -func (u *SignedInUser) GetAccess() claims.AccessClaims { - return &identity.IDClaimsWrapper{Source: u} +func (u *SignedInUser) GetID() string { + ns, id := u.getTypeAndID() + return claims.NewTypeID(ns, id) } -// Identity implements claims.AuthInfo. -func (u *SignedInUser) GetIdentity() claims.IdentityClaims { - if u.IDTokenClaims != nil { - return authnlib.NewIdentityClaims(*u.IDTokenClaims) - } - return &identity.IDClaimsWrapper{Source: u} +func (u *SignedInUser) GetInternalID() (int64, error) { + return identity.IntIdentifier(u.GetID()) } -// GetRawIdentifier implements Requester. -func (u *SignedInUser) GetRawIdentifier() string { +func (u *SignedInUser) GetUID() string { + return claims.NewTypeID(u.GetIdentityType(), u.GetIdentifier()) +} + +func (u *SignedInUser) GetIdentifier() string { if u.UserUID == "" { // nolint:staticcheck id, _ := u.GetInternalID() @@ -75,12 +75,10 @@ func (u *SignedInUser) GetRawIdentifier() string { return u.UserUID } -// GetInternalID implements Requester. -func (u *SignedInUser) GetInternalID() (int64, error) { - return identity.IntIdentifier(u.GetID()) +func (u *SignedInUser) GetRawIdentifier() string { + return u.GetIdentifier() } -// GetIdentityType implements Requester. func (u *SignedInUser) GetIdentityType() claims.IdentityType { switch { case u.ApiKeyID != 0: @@ -97,7 +95,6 @@ func (u *SignedInUser) GetIdentityType() claims.IdentityType { return u.FallbackType } -// IsIdentityType implements Requester. func (u *SignedInUser) IsIdentityType(expected ...claims.IdentityType) bool { return claims.IsIdentityType(u.GetIdentityType(), expected...) } @@ -115,7 +112,21 @@ func (u *SignedInUser) GetName() string { return u.Email } -// GetExtra implements Requester. +func (u *SignedInUser) GetNamespace() string { + return u.Namespace +} + +func (u *SignedInUser) GetSubject() string { + return u.GetID() +} + +func (u *SignedInUser) GetAudience() []string { + if u.AccessTokenClaims != nil { + return u.AccessTokenClaims.Audience + } + return []string{} +} + func (u *SignedInUser) GetExtra() map[string][]string { extra := map[string][]string{} if u.IDToken != "" { @@ -127,7 +138,6 @@ func (u *SignedInUser) GetExtra() map[string][]string { return extra } -// GetGroups implements Requester. func (u *SignedInUser) GetGroups() []string { groups := []string{} for _, t := range u.Teams { @@ -136,6 +146,28 @@ func (u *SignedInUser) GetGroups() []string { return groups } +func (u *SignedInUser) GetTokenPermissions() []string { + if u.AccessTokenClaims != nil { + return u.AccessTokenClaims.Rest.Permissions + } + return []string{} +} + +func (u *SignedInUser) GetTokenDelegatedPermissions() []string { + if u.AccessTokenClaims != nil { + return u.AccessTokenClaims.Rest.DelegatedPermissions + } + return []string{} +} + +func (u *SignedInUser) GetEmail() string { + return u.Email +} + +func (u *SignedInUser) GetEmailVerified() bool { + return u.EmailVerified +} + func (u *SignedInUser) ShouldUpdateLastSeenAt() bool { return u.UserID > 0 && time.Since(u.LastSeenAt) > time.Minute*5 } @@ -171,10 +203,6 @@ func (u *SignedInUser) HasUniqueId() bool { return u.IsRealUser() || u.IsApiKeyUser() || u.IsServiceAccountUser() } -func (u *SignedInUser) GetNamespace() string { - return u.Namespace -} - // GetCacheKey returns a unique key for the entity. // Add an extra prefix to avoid collisions with other caches func (u *SignedInUser) GetCacheKey() string { @@ -193,7 +221,6 @@ func (u *SignedInUser) GetCacheKey() string { return fmt.Sprintf("%d-%s-%s", u.GetOrgID(), typ, id) } -// GetIsGrafanaAdmin returns true if the user is a server admin func (u *SignedInUser) GetIsGrafanaAdmin() bool { return u.IsGrafanaAdmin } @@ -204,6 +231,11 @@ func (u *SignedInUser) GetLogin() string { return u.Login } +// GetUsername implements identity.Requester. +func (u *SignedInUser) GetUsername() string { + return u.Login +} + // GetOrgID returns the ID of the active organization func (u *SignedInUser) GetOrgID() int64 { return u.OrgID @@ -252,12 +284,6 @@ func (u *SignedInUser) GetOrgRole() identity.RoleType { return u.OrgRole } -// GetID returns namespaced id for the entity -func (u *SignedInUser) GetID() string { - ns, id := u.getTypeAndID() - return claims.NewTypeID(ns, id) -} - func (u *SignedInUser) getTypeAndID() (claims.IdentityType, string) { switch { case u.ApiKeyID != 0: @@ -275,10 +301,6 @@ func (u *SignedInUser) getTypeAndID() (claims.IdentityType, string) { return u.FallbackType, strconv.FormatInt(u.UserID, 10) } -func (u *SignedInUser) GetUID() string { - return fmt.Sprintf("%s:%s", u.GetIdentityType(), u.GetRawIdentifier()) -} - func (u *SignedInUser) GetAuthID() string { return u.AuthID } @@ -301,16 +323,6 @@ func (u *SignedInUser) IsNil() bool { return u == nil } -// GetEmail returns the email of the active entity -// Can be empty. -func (u *SignedInUser) GetEmail() string { - return u.Email -} - -func (u *SignedInUser) IsEmailVerified() bool { - return u.EmailVerified -} - func (u *SignedInUser) GetIDToken() string { return u.IDToken } diff --git a/pkg/storage/unified/resource/cdk_backend.go b/pkg/storage/unified/resource/cdk_backend.go index d9aabec1005..34d49fd2fcc 100644 --- a/pkg/storage/unified/resource/cdk_backend.go +++ b/pkg/storage/unified/resource/cdk_backend.go @@ -108,7 +108,8 @@ func (s *cdkBackend) getPath(key *ResourceKey, rv int64) string { return buffer.String() } -func (s *cdkBackend) Namespaces(ctx context.Context) ([]string, error) { +// GetResourceStats implements Backend. +func (s *cdkBackend) GetResourceStats(ctx context.Context, minCount int) ([]ResourceStats, error) { return nil, fmt.Errorf("not implemented") } diff --git a/pkg/storage/unified/resource/client.go b/pkg/storage/unified/resource/client.go index c06cd065463..5bbd0d88b9a 100644 --- a/pkg/storage/unified/resource/client.go +++ b/pkg/storage/unified/resource/client.go @@ -142,13 +142,12 @@ func idTokenExtractor(ctx context.Context) (string, error) { // If no token is found, create an internal token. // This is a workaround for StaticRequester not having a signed ID token. if staticRequester, ok := authInfo.(*identity.StaticRequester); ok { - token, idClaims, err := createInternalToken(staticRequester) + token, _, err := createInternalToken(staticRequester) if err != nil { return "", fmt.Errorf("failed to create internal token: %w", err) } staticRequester.IDToken = token - staticRequester.IDTokenClaims = idClaims return token, nil } @@ -170,29 +169,28 @@ func createInternalToken(authInfo claims.AuthInfo) (string, *authnlib.Claims[aut return "", nil, err } - identity := authInfo.GetIdentity() now := time.Now() tokenTTL := 10 * time.Minute idClaims := &auth.IDClaims{ - Claims: &jwt.Claims{ - Audience: identity.Audience(), - Subject: identity.Subject(), + Claims: jwt.Claims{ + Audience: authInfo.GetAudience(), + Subject: authInfo.GetSubject(), Expiry: jwt.NewNumericDate(now.Add(tokenTTL)), IssuedAt: jwt.NewNumericDate(now), }, Rest: authnlib.IDTokenClaims{ - Namespace: identity.Namespace(), - Identifier: identity.Identifier(), - Type: identity.IdentityType(), + Namespace: authInfo.GetNamespace(), + Identifier: authInfo.GetIdentifier(), + Type: authInfo.GetIdentityType(), }, } - if claims.IsIdentityType(identity.IdentityType(), claims.TypeUser) { - idClaims.Rest.Email = identity.Email() - idClaims.Rest.EmailVerified = identity.EmailVerified() - idClaims.Rest.AuthenticatedBy = identity.AuthenticatedBy() - idClaims.Rest.Username = identity.Username() - idClaims.Rest.DisplayName = identity.DisplayName() + if claims.IsIdentityType(authInfo.GetIdentityType(), claims.TypeUser) { + idClaims.Rest.Email = authInfo.GetEmail() + idClaims.Rest.EmailVerified = authInfo.GetEmailVerified() + idClaims.Rest.AuthenticatedBy = authInfo.GetAuthenticatedBy() + idClaims.Rest.Username = authInfo.GetUsername() + idClaims.Rest.DisplayName = authInfo.GetName() } builder := jwt.Signed(signer).Claims(&idClaims.Rest).Claims(idClaims.Claims) diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index 56bfcacb3ce..c37f5124fd1 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -81,6 +81,7 @@ type searchSupport struct { access authz.AccessClient builders *builderCache initWorkers int + initMinSize int } var ( @@ -104,6 +105,7 @@ func newSearchSupport(opts SearchOptions, storage StorageBackend, access authz.A search: opts.Backend, log: slog.Default().With("logger", "resource-search"), initWorkers: opts.WorkerThreads, + initMinSize: opts.InitMinCount, } info, err := opts.Resources.GetDocumentBuilders() @@ -164,40 +166,22 @@ func (s *searchSupport) init(ctx context.Context) error { _, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Init") defer span.End() - // TODO, replace namespaces with a query that gets top values - namespaces, err := s.storage.Namespaces(ctx) - if err != nil { - return err - } - - // Hardcoded for now... should come from the query - kinds := []schema.GroupResource{ - {Group: "dashboard.grafana.app", Resource: "dashboards"}, - {Group: "playlist.grafana.app", Resource: "playlists"}, - } - totalBatchesIndexed := 0 group := errgroup.Group{} group.SetLimit(s.initWorkers) - // Prepare all the (large) indexes - // TODO, threading and query real information: - // SELECT namespace,"group",resource,COUNT(*),resource_version FROM resource - // GROUP BY "group", "resource", "namespace" - // ORDER BY resource_version desc; - for _, ns := range namespaces { - for _, gr := range kinds { - group.Go(func() error { - s.log.Debug("initializing search index", "namespace", ns, "gr", gr) - totalBatchesIndexed++ - _, _, err = s.build(ctx, NamespacedResource{ - Group: gr.Group, - Resource: gr.Resource, - Namespace: ns, - }, 10, 0) // TODO, approximate size - return err - }) - } + stats, err := s.storage.GetResourceStats(ctx, s.initMinSize) + if err != nil { + return err + } + + for _, info := range stats { + group.Go(func() error { + s.log.Debug("initializing search index", "namespace", info.Namespace, "group", info.Group, "resource", info.Resource) + totalBatchesIndexed++ + _, _, err = s.build(ctx, info.NamespacedResource, info.Count, info.ResourceVersion) + return err + }) } err = group.Wait() diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 30c6d0a6ade..614f8534ed7 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -95,7 +95,14 @@ type StorageBackend interface { // For HA setups, this will be more events than the local WriteEvent above! WatchWriteEvents(ctx context.Context) (<-chan *WrittenEvent, error) - Namespaces(ctx context.Context) ([]string, error) + GetResourceStats(ctx context.Context, minCount int) ([]ResourceStats, error) +} + +type ResourceStats struct { + NamespacedResource + + Count int64 + ResourceVersion int64 } // This interface is not exposed to end users directly @@ -133,6 +140,9 @@ type SearchOptions struct { // How many threads should build indexes WorkerThreads int + + // Skip building index on startup for small indexes + InitMinCount int } type ResourceServerOptions struct { diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index 0e8f45d173a..4be8773cf67 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -121,6 +121,38 @@ func (b *backend) Stop(_ context.Context) error { return nil } +// GetResourceStats implements Backend. +func (b *backend) GetResourceStats(ctx context.Context, minCount int) ([]resource.ResourceStats, error) { + _, span := b.tracer.Start(ctx, tracePrefix+".GetResourceStats") + defer span.End() + + req := &sqlStatsRequest{ + SQLTemplate: sqltemplate.New(b.dialect), + MinCount: minCount, // not used in query... yet? + } + + res := make([]resource.ResourceStats, 0, 100) + err := b.db.WithTx(ctx, ReadCommittedRO, func(ctx context.Context, tx db.Tx) error { + rows, err := dbutil.QueryRows(ctx, tx, sqlResourceStats, req) + if err != nil { + return err + } + for rows.Next() { + row := resource.ResourceStats{} + err = rows.Scan(&row.Namespace, &row.Group, &row.Resource, &row.Count, &row.ResourceVersion) + if err != nil { + return err + } + if row.Count > int64(minCount) { + res = append(res, row) + } + } + return err + }) + + return res, err +} + func (b *backend) WriteEvent(ctx context.Context, event resource.WriteEvent) (int64, error) { _, span := b.tracer.Start(ctx, tracePrefix+"WriteEvent") defer span.End() @@ -137,35 +169,6 @@ func (b *backend) WriteEvent(ctx context.Context, event resource.WriteEvent) (in } } -// Namespaces returns the list of unique namespaces in storage. -func (b *backend) Namespaces(ctx context.Context) ([]string, error) { - var namespaces []string - - err := b.db.WithTx(ctx, RepeatableRead, func(ctx context.Context, tx db.Tx) error { - rows, err := tx.QueryContext(ctx, "SELECT DISTINCT(namespace) FROM resource ORDER BY namespace;") - if err != nil { - return err - } - - defer func() { - _ = rows.Close() - }() - - for rows.Next() { - var ns string - err = rows.Scan(&ns) - if err != nil { - return err - } - namespaces = append(namespaces, ns) - } - - return nil - }) - - return namespaces, err -} - func (b *backend) create(ctx context.Context, event resource.WriteEvent) (int64, error) { ctx, span := b.tracer.Start(ctx, tracePrefix+"Create") defer span.End() diff --git a/pkg/storage/unified/sql/data/resource_stats.sql b/pkg/storage/unified/sql/data/resource_stats.sql new file mode 100644 index 00000000000..c5d8cc69589 --- /dev/null +++ b/pkg/storage/unified/sql/data/resource_stats.sql @@ -0,0 +1,12 @@ +SELECT + {{ .Ident "namespace" }}, + {{ .Ident "group" }}, + {{ .Ident "resource" }}, + COUNT(*), + MAX({{ .Ident "resource_version" }}) +FROM {{ .Ident "resource" }} +GROUP BY + {{ .Ident "namespace" }}, + {{ .Ident "group" }}, + {{ .Ident "resource" }} +; \ No newline at end of file diff --git a/pkg/storage/unified/sql/db/dbimpl/dbEngine.go b/pkg/storage/unified/sql/db/dbimpl/dbEngine.go index abcd707832a..ddc4aa4ca4d 100644 --- a/pkg/storage/unified/sql/db/dbimpl/dbEngine.go +++ b/pkg/storage/unified/sql/db/dbimpl/dbEngine.go @@ -25,6 +25,10 @@ func getEngineMySQL(getter confGetter) (*xorm.Engine, error) { // See: https://dev.mysql.com/doc/refman/en/sql-mode.html "@@SESSION.sql_mode": "ANSI", } + sslMode := getter.String("ssl_mode") + if sslMode == "true" || sslMode == "skip-verify" { + config.Params["tls"] = "preferred" + } tls := getter.String("tls") if tls != "" { config.Params["tls"] = tls diff --git a/pkg/storage/unified/sql/queries.go b/pkg/storage/unified/sql/queries.go index 7ce060e07a7..27fc77ad55c 100644 --- a/pkg/storage/unified/sql/queries.go +++ b/pkg/storage/unified/sql/queries.go @@ -31,6 +31,7 @@ var ( sqlResourceInsert = mustTemplate("resource_insert.sql") sqlResourceUpdate = mustTemplate("resource_update.sql") sqlResourceRead = mustTemplate("resource_read.sql") + sqlResourceStats = mustTemplate("resource_stats.sql") sqlResourceList = mustTemplate("resource_list.sql") sqlResourceHistoryList = mustTemplate("resource_history_list.sql") sqlResourceUpdateRV = mustTemplate("resource_update_rv.sql") @@ -71,6 +72,15 @@ func (r sqlResourceRequest) Validate() error { return nil // TODO } +type sqlStatsRequest struct { + sqltemplate.SQLTemplate + MinCount int +} + +func (r sqlStatsRequest) Validate() error { + return nil // TODO +} + type historyPollResponse struct { Key resource.ResourceKey ResourceVersion int64 diff --git a/pkg/storage/unified/sql/queries_test.go b/pkg/storage/unified/sql/queries_test.go index 788abe95dad..30fe25eed2f 100644 --- a/pkg/storage/unified/sql/queries_test.go +++ b/pkg/storage/unified/sql/queries_test.go @@ -219,5 +219,15 @@ func TestUnifiedStorageQueries(t *testing.T) { }, }, }, + + sqlResourceStats: { + { + Name: "query", + Data: &sqlStatsRequest{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + MinCount: 10, // Not yet used in query (only response filter) + }, + }, + }, }}) } diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index d234cfaa5df..a0fe7e230ad 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -65,6 +65,7 @@ func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg, }, tracer, reg), Resources: docs, WorkerThreads: 5, // from cfg? + InitMinCount: 1, } } diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index 6297c71fe78..2abe4aec365 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -13,7 +13,6 @@ import ( "github.com/grafana/authlib/claims" "github.com/grafana/dskit/services" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" infraDB "github.com/grafana/grafana/pkg/infra/db" @@ -98,6 +97,12 @@ func TestIntegrationBackendHappyPath(t *testing.T) { rv3, err = writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED) require.NoError(t, err) require.Greater(t, rv3, rv2) + + stats, err := backend.GetResourceStats(ctx, 0) + require.NoError(t, err) + require.Len(t, stats, 1) + require.Equal(t, int64(3), stats[0].Count) + require.Equal(t, rv3, stats[0].ResourceVersion) }) t.Run("Update item2", func(t *testing.T) { diff --git a/pkg/storage/unified/sql/testdata/mysql--resource_stats-query.sql b/pkg/storage/unified/sql/testdata/mysql--resource_stats-query.sql new file mode 100755 index 00000000000..3818b621a11 --- /dev/null +++ b/pkg/storage/unified/sql/testdata/mysql--resource_stats-query.sql @@ -0,0 +1,12 @@ +SELECT + `namespace`, + `group`, + `resource`, + COUNT(*), + MAX(`resource_version`) +FROM `resource` +GROUP BY + `namespace`, + `group`, + `resource` +; diff --git a/pkg/storage/unified/sql/testdata/postgres--resource_stats-query.sql b/pkg/storage/unified/sql/testdata/postgres--resource_stats-query.sql new file mode 100755 index 00000000000..095b0a8011b --- /dev/null +++ b/pkg/storage/unified/sql/testdata/postgres--resource_stats-query.sql @@ -0,0 +1,12 @@ +SELECT + "namespace", + "group", + "resource", + COUNT(*), + MAX("resource_version") +FROM "resource" +GROUP BY + "namespace", + "group", + "resource" +; diff --git a/pkg/storage/unified/sql/testdata/sqlite--resource_stats-query.sql b/pkg/storage/unified/sql/testdata/sqlite--resource_stats-query.sql new file mode 100755 index 00000000000..095b0a8011b --- /dev/null +++ b/pkg/storage/unified/sql/testdata/sqlite--resource_stats-query.sql @@ -0,0 +1,12 @@ +SELECT + "namespace", + "group", + "resource", + COUNT(*), + MAX("resource_version") +FROM "resource" +GROUP BY + "namespace", + "group", + "resource" +; diff --git a/pkg/tests/api/shorturl/short_url_test.go b/pkg/tests/api/shorturl/short_url_test.go new file mode 100644 index 00000000000..e598c36ea7a --- /dev/null +++ b/pkg/tests/api/shorturl/short_url_test.go @@ -0,0 +1,130 @@ +package shorturl + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestShortURL(t *testing.T) { + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + username, password := "viewer", "viewer" + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: user.Password(password), + Login: username, + }) + + c := client(grafanaListedAddr, username, password) + + // Create a valid short-urls + res, err := c.post("/api/short-urls", bytes.NewReader([]byte(`{"path":"explore"}`))) + require.NoError(t, err) + defer func() { + _ = res.Body.Close() + }() + require.Equal(t, http.StatusOK, res.StatusCode) + + bodyRaw, err := io.ReadAll(res.Body) + require.NoError(t, err) + + resParsed := struct { + UID string `json:"uid"` + }{} + err = json.Unmarshal(bodyRaw, &resParsed) + require.NoError(t, err) + + // If the go-to exists, it should be set in the location and the status should match 302. + res, err = c.get(fmt.Sprintf("/goto/%s", resParsed.UID)) + require.NoError(t, err) + defer func() { + _ = res.Body.Close() + }() + require.Equal(t, "http://localhost:3000/explore", res.Header.Get("Location")) + require.Equal(t, http.StatusFound, res.StatusCode) + + // If the go-to does not exist, it should redirect to the home page and return 308. + res, err = c.get("/goto/DoesNotExist") + require.NoError(t, err) + defer func() { + _ = res.Body.Close() + }() + require.Equal(t, "http://localhost:3000/", res.Header.Get("Location")) + require.Equal(t, http.StatusPermanentRedirect, res.StatusCode) +} + +func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { + t.Helper() + + cfg.AutoAssignOrg = true + cfg.AutoAssignOrgId = 1 + + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService( + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) + require.NoError(t, err) + + u, err := usrSvc.Create(context.Background(), &cmd) + require.NoError(t, err) + return u.ID +} + +type apiClient struct { + url string +} + +func client(host, user, pass string) apiClient { + if len(user) == 0 && len(pass) == 0 { + return apiClient{url: fmt.Sprintf("http://%s", host)} + } + return apiClient{url: fmt.Sprintf("http://%s:%s@%s", user, pass, host)} +} + +func (a apiClient) get(path string) (*http.Response, error) { + u := fmt.Sprintf("%s%s", a.url, path) + // Setup a client that doesn't follow redirects. + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + // nolint:gosec + return client.Get(u) +} + +func (a apiClient) post(path string, body io.Reader) (*http.Response, error) { + u := fmt.Sprintf("%s%s", a.url, path) + // nolint:gosec + return http.Post(u, "application/json", body) +} diff --git a/pkg/tsdb/cloudwatch/annotation_query.go b/pkg/tsdb/cloudwatch/annotation_query.go index 074f30d5096..d2621419852 100644 --- a/pkg/tsdb/cloudwatch/annotation_query.go +++ b/pkg/tsdb/cloudwatch/annotation_query.go @@ -59,7 +59,8 @@ func (e *cloudWatchExecutor) executeAnnotationQuery(ctx context.Context, pluginC cli, err := e.getCWClient(ctx, pluginCtx, region) if err != nil { - return nil, err + result = errorsource.AddDownstreamErrorToResponse(query.RefID, result, fmt.Errorf("%v: %w", "failed to get client", err)) + return result, nil } var alarmNames []*string diff --git a/pkg/tsdb/cloudwatch/log_sync_query.go b/pkg/tsdb/cloudwatch/log_sync_query.go index 3baaac9c076..f5193863e45 100644 --- a/pkg/tsdb/cloudwatch/log_sync_query.go +++ b/pkg/tsdb/cloudwatch/log_sync_query.go @@ -24,7 +24,8 @@ var executeSyncLogQuery = func(ctx context.Context, e *cloudWatchExecutor, req * instance, err := e.getInstance(ctx, req.PluginContext) if err != nil { - return nil, err + errorsource.AddErrorToResponse(req.Queries[0].RefID, resp, err) + return resp, nil } for _, q := range req.Queries { diff --git a/pkg/tsdb/cloudwatch/models/settings.go b/pkg/tsdb/cloudwatch/models/settings.go index 58e25d736e4..aa341f4d073 100644 --- a/pkg/tsdb/cloudwatch/models/settings.go +++ b/pkg/tsdb/cloudwatch/models/settings.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana-aws-sdk/pkg/awsds" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" ) type Duration struct { @@ -61,13 +62,16 @@ func (duration *Duration) UnmarshalJSON(b []byte) error { case float64: *duration = Duration{time.Duration(value)} case string: + if value == "" { + return nil + } dur, err := time.ParseDuration(value) if err != nil { - return err + return errorsource.DownstreamError(err, false) } *duration = Duration{dur} default: - return fmt.Errorf("invalid duration: %#v", unmarshalledJson) + return errorsource.DownstreamError(fmt.Errorf("invalid duration: %#v", unmarshalledJson), false) } return nil diff --git a/pkg/tsdb/cloudwatch/models/settings_test.go b/pkg/tsdb/cloudwatch/models/settings_test.go index 8b6579ac1ea..ed51c54fb05 100644 --- a/pkg/tsdb/cloudwatch/models/settings_test.go +++ b/pkg/tsdb/cloudwatch/models/settings_test.go @@ -2,12 +2,14 @@ package models import ( "context" + "errors" "testing" "time" "github.com/grafana/grafana-aws-sdk/pkg/awsds" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" + "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -114,6 +116,24 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) { require.NoError(t, err) assert.Equal(t, time.Minute*30, s.LogsTimeout.Duration) }) + t.Run("Should set logsTimeout to default duration if it is empty string", func(t *testing.T) { + settings := backend.DataSourceInstanceSettings{ + ID: 33, + JSONData: []byte(`{ + "authType": "arn", + "assumeRoleArn": "arn:aws:iam::123456789012:role/grafana", + "logsTimeout": "" + }`), + DecryptedSecureJSONData: map[string]string{ + "accessKey": "AKIAIOSFODNN7EXAMPLE", + "secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + } + + s, err := LoadCloudWatchSettings(settingCtx, settings) + require.NoError(t, err) + assert.Equal(t, time.Minute*30, s.LogsTimeout.Duration) + }) t.Run("Should correctly parse logsTimeout duration string", func(t *testing.T) { settings := backend.DataSourceInstanceSettings{ ID: 33, @@ -184,6 +204,10 @@ func Test_Settings_LoadCloudWatchSettings(t *testing.T) { _, err := LoadCloudWatchSettings(context.Background(), settings) require.Error(t, err) + var sourceErr errorsource.Error + ok := errors.As(err, &sourceErr) + require.True(t, ok) + require.Equal(t, sourceErr.ErrorSource().String(), "downstream") }) t.Run("Should throw error if logsTimeout is an invalid type", func(t *testing.T) { settings := backend.DataSourceInstanceSettings{ diff --git a/pkg/tsdb/cloudwatch/time_series_query.go b/pkg/tsdb/cloudwatch/time_series_query.go index e872c319d76..4025aeba77c 100644 --- a/pkg/tsdb/cloudwatch/time_series_query.go +++ b/pkg/tsdb/cloudwatch/time_series_query.go @@ -35,7 +35,8 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, req *ba instance, err := e.getInstance(ctx, req.PluginContext) if err != nil { - return nil, err + errorsource.AddErrorToResponse(req.Queries[0].RefID, resp, err) + return resp, nil } requestQueries, err := models.ParseMetricDataQueries(req.Queries, startTime, endTime, instance.Settings.Region, e.logger.FromContext(ctx), diff --git a/pkg/tsdb/elasticsearch/healthcheck.go b/pkg/tsdb/elasticsearch/healthcheck.go index e94ec8d1667..6218aea2754 100644 --- a/pkg/tsdb/elasticsearch/healthcheck.go +++ b/pkg/tsdb/elasticsearch/healthcheck.go @@ -3,6 +3,7 @@ package elasticsearch import ( "context" "encoding/json" + "fmt" "io" "net/http" "net/url" @@ -10,6 +11,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" ) func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { @@ -51,6 +53,9 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque if err != nil { logger.Error("Failed to do healthcheck request", "error", err, "url", esUrl.String()) + if backend.IsDownstreamHTTPError(err) { + err = errorsource.DownstreamError(err, false) + } return &backend.CheckHealthResult{ Status: backend.HealthStatusUnknown, Message: "Failed to do healthcheck request", @@ -64,6 +69,14 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque }, nil } + if response.StatusCode >= 400 { + errWithSource := errorsource.SourceError(backend.ErrorSourceFromHTTPStatus(response.StatusCode), fmt.Errorf("unexpected status code: %d", response.StatusCode), false) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusError, + Message: fmt.Sprintf("Elasticsearch data source is not healthy. Status: %s", response.Status), + }, errWithSource + } + logger.Info("Response received from Elasticsearch", "statusCode", response.StatusCode, "status", "ok", "duration", time.Since(start)) defer func() { diff --git a/pkg/tsdb/elasticsearch/healthcheck_test.go b/pkg/tsdb/elasticsearch/healthcheck_test.go index 12a6b6b7665..93b6c1252cd 100644 --- a/pkg/tsdb/elasticsearch/healthcheck_test.go +++ b/pkg/tsdb/elasticsearch/healthcheck_test.go @@ -16,7 +16,7 @@ import ( ) func Test_Healthcheck_OK(t *testing.T) { - service := GetMockService(true) + service := GetMockService(http.StatusOK, "200 OK") res, _ := service.CheckHealth(context.Background(), &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{}, Headers: nil, @@ -26,7 +26,7 @@ func Test_Healthcheck_OK(t *testing.T) { } func Test_Healthcheck_Timeout(t *testing.T) { - service := GetMockService(false) + service := GetMockService(http.StatusRequestTimeout, "408 Request Timeout") res, _ := service.CheckHealth(context.Background(), &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{}, Headers: nil, @@ -35,13 +35,24 @@ func Test_Healthcheck_Timeout(t *testing.T) { assert.Equal(t, "Elasticsearch data source is not healthy", res.Message) } +func Test_Healthcheck_Error(t *testing.T) { + service := GetMockService(http.StatusBadGateway, "502 Bad Gateway") + res, _ := service.CheckHealth(context.Background(), &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{}, + Headers: nil, + }) + assert.Equal(t, backend.HealthStatusError, res.Status) + assert.Equal(t, "Elasticsearch data source is not healthy. Status: 502 Bad Gateway", res.Message) +} + type FakeRoundTripper struct { - isDsHealthy bool + statusCode int + status string } func (fakeRoundTripper *FakeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { var res *http.Response - if fakeRoundTripper.isDsHealthy { + if fakeRoundTripper.statusCode == http.StatusOK { res = &http.Response{ StatusCode: http.StatusOK, Status: "200 OK", @@ -49,8 +60,8 @@ func (fakeRoundTripper *FakeRoundTripper) RoundTrip(req *http.Request) (*http.Re } } else { res = &http.Response{ - StatusCode: http.StatusRequestTimeout, - Status: "408 Request Timeout", + StatusCode: fakeRoundTripper.statusCode, + Status: fakeRoundTripper.status, Body: io.NopCloser(bytes.NewBufferString("{\"status\":\"red\"}")), } } @@ -58,12 +69,13 @@ func (fakeRoundTripper *FakeRoundTripper) RoundTrip(req *http.Request) (*http.Re } type FakeInstanceManager struct { - isDsHealthy bool + statusCode int + status string } func (fakeInstanceManager *FakeInstanceManager) Get(tx context.Context, pluginContext backend.PluginContext) (instancemgmt.Instance, error) { httpClient, _ := httpclient.New(httpclient.Options{}) - httpClient.Transport = &FakeRoundTripper{isDsHealthy: fakeInstanceManager.isDsHealthy} + httpClient.Transport = &FakeRoundTripper{statusCode: fakeInstanceManager.statusCode, status: fakeInstanceManager.status} return es.DatasourceInfo{ HTTPClient: httpClient, @@ -74,9 +86,9 @@ func (*FakeInstanceManager) Do(_ context.Context, _ backend.PluginContext, _ ins return nil } -func GetMockService(isDsHealthy bool) *Service { +func GetMockService(statusCode int, status string) *Service { return &Service{ - im: &FakeInstanceManager{isDsHealthy: isDsHealthy}, + im: &FakeInstanceManager{statusCode: statusCode, status: status}, logger: log.New(), } } diff --git a/pkg/util/xorm/go.mod b/pkg/util/xorm/go.mod index 2416c16f0c8..b923d8e7ea9 100644 --- a/pkg/util/xorm/go.mod +++ b/pkg/util/xorm/go.mod @@ -6,7 +6,7 @@ toolchain go1.23.1 require ( github.com/mattn/go-sqlite3 v1.14.22 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 xorm.io/builder v0.3.6 xorm.io/core v0.7.3 ) diff --git a/pkg/util/xorm/go.sum b/pkg/util/xorm/go.sum index 28f6e71793a..c1d214d6716 100644 --- a/pkg/util/xorm/go.sum +++ b/pkg/util/xorm/go.sum @@ -30,8 +30,8 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index 1d86bd94020..d2c775085d5 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -4501,6 +4501,21 @@ } } }, + "EmailSchema": { + "description": "Implementation of SCIM specification email schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.2", + "type": "object", + "properties": { + "primary": { + "type": "boolean" + }, + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "EnumFieldConfig": { "description": "Enum field config\nVector values are used as lookup keys into the enum fields", "type": "object", @@ -5016,6 +5031,35 @@ } } }, + "GetUsersBody": { + "type": "object", + "properties": { + "Resources": { + "type": "array", + "items": { + "$ref": "#/definitions/UserSchema" + } + }, + "itemsPerPage": { + "type": "integer", + "format": "int64" + }, + "schemas": { + "type": "array", + "items": { + "type": "string" + } + }, + "startIndex": { + "type": "integer", + "format": "int64" + }, + "totalResults": { + "type": "integer", + "format": "int64" + } + } + }, "Group": { "type": "object", "properties": { @@ -5553,6 +5597,29 @@ } } }, + "MetaSchema": { + "description": "Implementation of SCIM specification meta schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-3.1", + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "location": { + "type": "string" + }, + "resourceType": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, "Metadata": { "description": "Metadata contains user accesses for a given resource\nEx: map[string]bool{\"create\":true, \"delete\": true}", "type": "object", @@ -5737,6 +5804,24 @@ } } }, + "NameSchema": { + "description": "Implementation of SCIM specification name schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.1", + "type": "object", + "properties": { + "familyName": { + "type": "string" + }, + "formatted": { + "type": "string" + }, + "givenName": { + "type": "string" + }, + "middleName": { + "type": "string" + } + } + }, "NavbarPreference": { "type": "object", "title": "NavbarPreference defines model for NavbarPreference.", @@ -8566,6 +8651,45 @@ } } }, + "UserSchema": { + "description": "Implementation of SCIM specification user schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.1", + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "displayName": { + "type": "string" + }, + "emails": { + "type": "array", + "items": { + "$ref": "#/definitions/EmailSchema" + } + }, + "externalId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/MetaSchema" + }, + "name": { + "$ref": "#/definitions/NameSchema" + }, + "schemas": { + "type": "array", + "items": { + "type": "string" + } + }, + "userName": { + "type": "string" + } + } + }, "UserSearchHitDTO": { "type": "object", "properties": { @@ -9769,6 +9893,12 @@ } } }, + "getUsers": { + "description": "", + "schema": { + "$ref": "#/definitions/GetUsersBody" + } + }, "goneError": { "description": "GoneError is returned when the requested endpoint was removed.", "schema": { diff --git a/public/api-merged.json b/public/api-merged.json index bbbc58b0740..1978029ed6c 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -8607,6 +8607,30 @@ } }, "/scim/users/": { + "get": { + "tags": [ + "users" + ], + "summary": "Fetches all users in UserSchema format.", + "operationId": "getUsers", + "responses": { + "200": { + "$ref": "#/responses/getUsers" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, "post": { "tags": [ "users" @@ -8617,6 +8641,9 @@ "200": { "$ref": "#/responses/okResponse" }, + "400": { + "$ref": "#/responses/badRequestError" + }, "401": { "$ref": "#/responses/unauthorisedError" }, @@ -8630,6 +8657,56 @@ "$ref": "#/responses/internalServerError" } } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Deletes user.", + "operationId": "deleteUser", + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/scim/users/:id": { + "get": { + "tags": [ + "user" + ], + "summary": "Gets user by id.", + "operationId": "getUser", + "responses": { + "200": { + "$ref": "#/responses/okResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } } }, "/search": { @@ -15065,6 +15142,21 @@ } } }, + "EmailSchema": { + "description": "Implementation of SCIM specification email schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.2", + "type": "object", + "properties": { + "primary": { + "type": "boolean" + }, + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "EmbeddedContactPoint": { "description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.", "type": "object", @@ -15779,6 +15871,35 @@ } } }, + "GetUsersBody": { + "type": "object", + "properties": { + "Resources": { + "type": "array", + "items": { + "$ref": "#/definitions/UserSchema" + } + }, + "itemsPerPage": { + "type": "integer", + "format": "int64" + }, + "schemas": { + "type": "array", + "items": { + "type": "string" + } + }, + "startIndex": { + "type": "integer", + "format": "int64" + }, + "totalResults": { + "type": "integer", + "format": "int64" + } + } + }, "GettableAlertmanagers": { "type": "object", "properties": { @@ -17075,6 +17196,29 @@ "$ref": "#/definitions/Matcher" } }, + "MetaSchema": { + "description": "Implementation of SCIM specification meta schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-3.1", + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "location": { + "type": "string" + }, + "resourceType": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, "Metadata": { "description": "Metadata contains user accesses for a given resource\nEx: map[string]bool{\"create\":true, \"delete\": true}", "type": "object", @@ -17301,6 +17445,24 @@ } } }, + "NameSchema": { + "description": "Implementation of SCIM specification name schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.1", + "type": "object", + "properties": { + "familyName": { + "type": "string" + }, + "formatted": { + "type": "string" + }, + "givenName": { + "type": "string" + }, + "middleName": { + "type": "string" + } + } + }, "NamespaceConfigResponse": { "type": "object", "additionalProperties": { @@ -22163,6 +22325,45 @@ } } }, + "UserSchema": { + "description": "Implementation of SCIM specification user schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.1", + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "displayName": { + "type": "string" + }, + "emails": { + "type": "array", + "items": { + "$ref": "#/definitions/EmailSchema" + } + }, + "externalId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/MetaSchema" + }, + "name": { + "$ref": "#/definitions/NameSchema" + }, + "schemas": { + "type": "array", + "items": { + "type": "string" + } + }, + "userName": { + "type": "string" + } + } + }, "UserSearchHitDTO": { "type": "object", "properties": { @@ -24138,6 +24339,12 @@ } } }, + "getUsers": { + "description": "(empty)", + "schema": { + "$ref": "#/definitions/GetUsersBody" + } + }, "goneError": { "description": "GoneError is returned when the requested endpoint was removed.", "schema": { diff --git a/public/app/features/auth-config/components/ServerDiscoveryField.tsx b/public/app/features/auth-config/components/ServerDiscoveryField.tsx index 5c8a2e3f441..71dd8f7a70f 100644 --- a/public/app/features/auth-config/components/ServerDiscoveryField.tsx +++ b/public/app/features/auth-config/components/ServerDiscoveryField.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { UseFormSetValue } from 'react-hook-form'; import { AppEvents } from '@grafana/data'; import { getAppEvents, getBackendSrv } from '@grafana/runtime'; diff --git a/public/app/features/auth-config/types.ts b/public/app/features/auth-config/types.ts index e35622b06c5..7f15496ecd7 100644 --- a/public/app/features/auth-config/types.ts +++ b/public/app/features/auth-config/types.ts @@ -1,6 +1,5 @@ import { ReactElement } from 'react'; -import { Validate } from 'react-hook-form'; -import { UseFormSetValue } from 'react-hook-form/dist/types/form'; +import { Validate, UseFormSetValue } from 'react-hook-form'; import { IconName, SelectableValue } from '@grafana/data'; import { Settings } from 'app/types'; diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index 7c9770aaf4d..f470946e40f 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -263,19 +263,14 @@ export const browseDashboardsAPI = createApi({ for (const dashboardUID of selectedDashboards) { const fullDash: DashboardDTO = await getDashboardAPI().getDashboardDTO(dashboardUID); - const options = { + await getDashboardAPI().saveDashboard({ dashboard: fullDash.dashboard, folderUid: destinationUID, overwrite: false, message: '', - }; - - await baseQuery({ - url: `/dashboards/db`, - method: 'POST', - data: options, }); } + return { data: undefined }; }, onQueryStarted: ({ destinationUID, selectedItems }, { queryFulfilled, dispatch }) => { @@ -314,11 +309,8 @@ export const browseDashboardsAPI = createApi({ // Delete all the dashboards sequentially // TODO error handling here for (const dashboardUID of selectedDashboards) { - const response = await baseQuery({ - url: `/dashboards/uid/${dashboardUID}`, - method: 'DELETE', - showSuccessAlert: false, - }); + const response = getDashboardAPI().deleteDashboard(dashboardUID, false); + // @ts-expect-error const name = response?.data?.title; diff --git a/public/app/features/dashboard-scene/settings/VariablesEditView.tsx b/public/app/features/dashboard-scene/settings/VariablesEditView.tsx index ffdfe4e7856..18e4382eca4 100644 --- a/public/app/features/dashboard-scene/settings/VariablesEditView.tsx +++ b/public/app/features/dashboard-scene/settings/VariablesEditView.tsx @@ -1,15 +1,20 @@ +import { useMemo } from 'react'; + import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data'; import { SceneComponentProps, SceneObjectBase, SceneVariable, SceneVariables, sceneGraph } from '@grafana/scenes'; import { Page } from 'app/core/components/Page/Page'; import { DashboardScene } from '../scene/DashboardScene'; import { NavToolbarActions } from '../scene/NavToolbarActions'; +import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { getDashboardSceneFor } from '../utils/utils'; +import { createUsagesNetwork, transformUsagesToNetwork } from '../variables/utils'; import { EditListViewSceneUrlSync } from './EditListViewSceneUrlSync'; import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils'; import { VariableEditorForm } from './variables/VariableEditorForm'; import { VariableEditorList } from './variables/VariableEditorList'; +import { VariablesUnknownTable } from './variables/VariablesUnknownTable'; import { EditableVariableType, RESERVED_GLOBAL_VARIABLE_NAME_REGEX, @@ -17,6 +22,7 @@ import { getVariableDefault, getVariableScene, } from './variables/utils'; + export interface VariablesEditViewState extends DashboardEditViewState { editIndex?: number | undefined; } @@ -197,6 +203,22 @@ export class VariablesEditView extends SceneObjectBase i return [false, null]; }; + + public getSaveModel = () => { + return transformSceneToSaveModel(this.getDashboard()); + }; + + public getUsages = () => { + const model = this.getSaveModel(); + const usages = createUsagesNetwork(this.getVariables(), model); + return usages; + }; + + public getUsagesNetwork = () => { + const usages = this.getUsages(); + const usagesNetwork = transformUsagesToNetwork(usages); + return usagesNetwork; + }; } function VariableEditorSettingsListView({ model }: SceneComponentProps) { @@ -206,6 +228,9 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps model.getUsagesNetwork(), [model]); + const usages = useMemo(() => model.getUsages(), [model]); + const saveModel = model.getSaveModel(); if (editIndex !== undefined && variables[editIndex]) { const variable = variables[editIndex]; @@ -230,12 +255,15 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps + ); } diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx index 8a579c1d792..d4a902b7acf 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx @@ -10,11 +10,14 @@ import { useStyles2, Stack, Button, EmptyState, TextLink } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; import { VariablesDependenciesButton } from '../../variables/VariablesDependenciesButton'; +import { UsagesToNetwork, VariableUsageTree } from '../../variables/utils'; import { VariableEditorListRow } from './VariableEditorListRow'; export interface Props { variables: Array>; + usages: VariableUsageTree[]; + usagesNetwork: UsagesToNetwork[]; onAdd: () => void; onChangeOrder: (fromIndex: number, toIndex: number) => void; onDuplicate: (identifier: string) => void; @@ -24,6 +27,8 @@ export interface Props { export function VariableEditorList({ variables, + usages, + usagesNetwork, onChangeOrder, onDelete, onDuplicate, @@ -71,6 +76,8 @@ export function VariableEditorList({ onDelete={onDelete} onDuplicate={onDuplicate} onEdit={onEdit} + usageTree={usages} + usagesNetwork={usagesNetwork} /> ); })} diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorListRow.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorListRow.tsx index 9d95b792407..460b82bb351 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditorListRow.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorListRow.tsx @@ -6,13 +6,18 @@ import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; import { SceneVariable } from '@grafana/scenes'; -import { Button, ConfirmModal, Icon, IconButton, useStyles2, useTheme2 } from '@grafana/ui'; +import { Button, ConfirmModal, Icon, IconButton, Tooltip, useStyles2, useTheme2 } from '@grafana/ui'; + +import { VariableUsagesButton } from '../../variables/VariableUsagesButton'; +import { UsagesToNetwork, VariableUsageTree, getVariableUsages } from '../../variables/utils'; import { getDefinition } from './utils'; export interface VariableEditorListRowProps { index: number; variable: SceneVariable; + usageTree: VariableUsageTree[]; + usagesNetwork: UsagesToNetwork[]; onEdit: (identifier: string) => void; onDuplicate: (identifier: string) => void; onDelete: (identifier: string) => void; @@ -21,6 +26,8 @@ export interface VariableEditorListRowProps { export function VariableEditorListRow({ index, variable, + usageTree, + usagesNetwork, onEdit: propsOnEdit, onDuplicate: propsOnDuplicate, onDelete: propsOnDelete, @@ -30,6 +37,8 @@ export function VariableEditorListRow({ const definition = getDefinition(variable); const variableState = variable.state; const identifier = variableState.name; + const usages = getVariableUsages(identifier, usageTree); + const passed = usages > 0 || variableState.type === 'adhoc'; const [showDeleteModal, setShowDeleteModal] = useState(false); const handleDeleteVariableModal = (show: boolean) => () => { setShowDeleteModal(show); @@ -79,6 +88,12 @@ export function VariableEditorListRow({
+ + { event.preventDefault(); @@ -122,6 +137,35 @@ export function VariableEditorListRow({ ); } +interface VariableCheckIndicatorProps { + passed: boolean; +} + +function VariableCheckIndicator({ passed }: VariableCheckIndicatorProps): ReactElement { + const styles = useStyles2(getStyles); + if (passed) { + return ( + + + + ); + } + + return ( + + + + ); +} + function getStyles(theme: GrafanaTheme2) { return { dragHandle: css({ diff --git a/public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.test.tsx b/public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.test.tsx new file mode 100644 index 00000000000..c05b0c83afe --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.test.tsx @@ -0,0 +1,151 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import * as runtime from '@grafana/runtime'; +import { TestVariable } from '@grafana/scenes'; + +import * as utils from '../../variables/utils'; +import { UsagesToNetwork } from '../../variables/utils'; + +import { + SLOW_VARIABLES_EXPANSION_THRESHOLD, + VariablesUnknownTable, + VariablesUnknownTableProps, +} from './VariablesUnknownTable'; + +async function getTestContext( + overrides: Partial | undefined = {}, + usages: UsagesToNetwork[] = [] +) { + jest.clearAllMocks(); + const reportInteractionSpy = jest.spyOn(runtime, 'reportInteraction').mockImplementation(); + const getUnknownsNetworkSpy = jest.spyOn(utils, 'getUnknownsNetwork').mockResolvedValue(usages); + const defaults: VariablesUnknownTableProps = { + variables: [], + dashboard: null, + }; + const props = { ...defaults, ...overrides }; + const { rerender } = render(); + await waitFor(() => + expect(screen.getByRole('heading', { name: /renamed or missing variables/i })).toBeInTheDocument() + ); + + return { reportInteractionSpy, getUnknownsNetworkSpy, rerender }; +} + +describe('VariablesUnknownTable', () => { + describe('when rendered', () => { + it('then it should render the section header', async () => { + await getTestContext(); + }); + }); + + describe('when expanding the section', () => { + it('then it should call getUnknownsNetwork', async () => { + const { getUnknownsNetworkSpy } = await getTestContext(); + + await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitFor(() => expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1)); + }); + + it('then it should report the interaction', async () => { + const { reportInteractionSpy } = await getTestContext(); + + await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + + expect(reportInteractionSpy).toHaveBeenCalledTimes(1); + expect(reportInteractionSpy).toHaveBeenCalledWith('Unknown variables section expanded'); + }); + + describe('but when expanding it again without changes to variables or dashboard', () => { + it('then it should not call getUnknownsNetwork', async () => { + const { getUnknownsNetworkSpy } = await getTestContext(); + + await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true')); + expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false')); + + await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true')); + + expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('and there are no renamed or missing variables', () => { + it('then it should render the correct message', async () => { + await getTestContext(); + + await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + + expect(screen.getByText('No renamed or missing variables found.')).toBeInTheDocument(); + }); + }); + + describe('and there are renamed or missing variables', () => { + it('then it should render the table', async () => { + const variable = new TestVariable({ name: 'Renamed Variable', query: 'A.*', value: '', text: '', options: [] }); + const usages = [{ variable, nodes: [], edges: [], showGraph: false }]; + const { reportInteractionSpy } = await getTestContext({}, usages); + + await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + + expect(screen.queryByText('No renamed or missing variables found.')).not.toBeInTheDocument(); + expect(screen.getByText('Renamed Variable')).toBeInTheDocument(); + expect(screen.getByLabelText('Show usages')).toBeInTheDocument(); + + // make sure we don't report the interaction for slow expansion + expect(reportInteractionSpy).toHaveBeenCalledTimes(1); + expect(reportInteractionSpy).toHaveBeenCalledWith('Unknown variables section expanded'); + }); + + describe('but when the unknown processing takes a while', () => { + let user: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + // Need to use delay: null here to work with fakeTimers + // see https://github.com/testing-library/user-event/issues/833 + user = userEvent.setup({ delay: null }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('then it should report slow expansion', async () => { + const variable = new TestVariable({ + name: 'Renamed Variable', + query: 'A.*', + value: '', + text: '', + options: [], + }); + const usages = [{ variable, nodes: [], edges: [], showGraph: false }]; + const { getUnknownsNetworkSpy, reportInteractionSpy } = await getTestContext({}, usages); + getUnknownsNetworkSpy.mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(usages); + }, SLOW_VARIABLES_EXPANSION_THRESHOLD); + }); + }); + + await user.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + + jest.advanceTimersByTime(SLOW_VARIABLES_EXPANSION_THRESHOLD); + + // make sure we report the interaction for slow expansion + await waitFor(() => + expect(reportInteractionSpy).toHaveBeenCalledWith('Slow unknown variables expansion', { + elapsed: expect.any(Number), + }) + ); + }); + }); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.tsx b/public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.tsx new file mode 100644 index 00000000000..9b96d5bc9ae --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.tsx @@ -0,0 +1,154 @@ +import { css } from '@emotion/css'; +import { ReactElement, useEffect, useState } from 'react'; +import { useAsyncFn } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { SceneVariable, SceneVariableState } from '@grafana/scenes'; +import { Dashboard } from '@grafana/schema/dist/esm/index.gen'; +import { CollapsableSection, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +import { VariableUsagesButton } from '../../variables/VariableUsagesButton'; +import { getUnknownsNetwork, UsagesToNetwork } from '../../variables/utils'; + +export const SLOW_VARIABLES_EXPANSION_THRESHOLD = 1000; + +export interface VariablesUnknownTableProps { + variables: Array>; + dashboard: Dashboard | null; +} + +export function VariablesUnknownTable({ variables, dashboard }: VariablesUnknownTableProps): ReactElement { + const [open, setOpen] = useState(false); + const [changed, setChanged] = useState(0); + const style = useStyles2(getStyles); + + useEffect(() => setChanged((prevState) => prevState + 1), [variables, dashboard]); + + const [{ loading, value: usages }, getUnknowns] = useAsyncFn(async () => { + const start = Date.now(); + const unknownsNetwork = await getUnknownsNetwork(variables, dashboard); + const stop = Date.now(); + const elapsed = stop - start; + if (elapsed >= SLOW_VARIABLES_EXPANSION_THRESHOLD) { + reportInteraction('Slow unknown variables expansion', { elapsed }); + } + setChanged(0); + + return unknownsNetwork; + }, [variables, dashboard]); + + const onToggle = (isOpen: boolean) => { + if (isOpen) { + reportInteraction('Unknown variables section expanded'); + + // make sure we only fetch when opened and variables or dashboard have changed + if (changed > 0) { + getUnknowns(); + } + } + + setOpen(isOpen); + }; + + return ( +
+ } isOpen={open} onToggle={onToggle}> + {loading || !usages ? ( + + + + Loading... + + + + + ) : usages.length > 0 ? ( + + ) : ( + + )} + +
+ ); +} + +function CollapseLabel(): ReactElement { + const style = useStyles2(getStyles); + return ( +
+ Renamed or missing variables + + + +
+ ); +} + +function NoUnknowns(): ReactElement { + return ( + + No renamed or missing variables found. + + ); +} + +function UnknownTable({ usages }: { usages: UsagesToNetwork[] }): ReactElement { + const style = useStyles2(getStyles); + return ( + + + + + + + + {usages.map((usage) => { + const name = typeof usage.variable === 'string' ? usage.variable : usage.variable.state.name; + return ( + + + + + ); + })} + +
+ Variable + +
+ {name} + + + + + +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + marginTop: theme.spacing(4), + paddingTop: theme.spacing(4), + }), + infoIcon: css({ + marginLeft: theme.spacing(1), + }), + defaultColumn: css({ + width: '1%', + }), + firstColumn: css({ + width: '1%', + verticalAlign: 'top', + color: theme.colors.text.maxContrast, + }), + lastColumn: css({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + width: '100%', + textAlign: 'right', + }), +}); diff --git a/public/app/features/dashboard-scene/variables/VariableUsagesButton.tsx b/public/app/features/dashboard-scene/variables/VariableUsagesButton.tsx new file mode 100644 index 00000000000..bc83a262bc7 --- /dev/null +++ b/public/app/features/dashboard-scene/variables/VariableUsagesButton.tsx @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; + +import { reportInteraction } from '@grafana/runtime'; +import { IconButton } from '@grafana/ui'; +import { NetworkGraphModal } from 'app/features/variables/inspect/NetworkGraphModal'; + +import { UsagesToNetwork } from './utils'; + +interface Props { + id: string; + usages: UsagesToNetwork[]; + isAdhoc: boolean; +} + +export const VariableUsagesButton = ({ id, usages, isAdhoc }: Props) => { + const network = useMemo( + () => usages.find((n) => (typeof n.variable === 'string' ? n.variable : n.variable.state.name) === id), + [usages, id] + ); + if (usages.length === 0 || isAdhoc || !network) { + return null; + } + + const nodes = network.nodes.map((n) => { + if (n.label.includes(`$${id}`)) { + return { ...n, color: '#FB7E81' }; + } + return n; + }); + + return ( + + {({ showModal }) => { + return ( + { + reportInteraction('Show variable usages'); + showModal(); + }} + name="code-branch" + tooltip="Show usages" + /> + ); + }} + + ); +}; diff --git a/public/app/features/dashboard-scene/variables/utils.test.ts b/public/app/features/dashboard-scene/variables/utils.test.ts index 9d5d99e4b88..4ddab168cb2 100644 --- a/public/app/features/dashboard-scene/variables/utils.test.ts +++ b/public/app/features/dashboard-scene/variables/utils.test.ts @@ -1,10 +1,17 @@ import { TestVariable } from '@grafana/scenes'; +import { Dashboard } from '@grafana/schema'; import { variableAdapters } from 'app/features/variables/adapters'; import { createCustomVariableAdapter } from 'app/features/variables/custom/adapter'; import { createDataSourceVariableAdapter } from 'app/features/variables/datasource/adapter'; import { createQueryVariableAdapter } from 'app/features/variables/query/adapter'; -import { createDependencyEdges, createDependencyNodes } from './utils'; +import { + createDependencyEdges, + getVariableName, + createDependencyNodes, + createUsagesNetwork, + transformUsagesToNetwork, +} from './utils'; variableAdapters.setInit(() => [ createDataSourceVariableAdapter(), @@ -12,6 +19,54 @@ variableAdapters.setInit(() => [ createQueryVariableAdapter(), ]); +const dashboardMock: Dashboard = { + panels: [ + { + datasource: { + type: 'prometheus', + uid: 'gdev-prometheus', + }, + targets: [ + { + datasource: { + type: 'prometheus', + uid: 'gdev-prometheus', + }, + disableTextWrap: false, + editorMode: 'code', + expr: 'go_gc_duration_seconds{job=$query0)', + fullMetaSearch: false, + includeNullMetadata: true, + instant: false, + legendFormat: '__auto', + range: true, + refId: 'A', + useBackend: false, + }, + { + datasource: { + type: 'prometheus', + uid: 'gdev-prometheus', + }, + disableTextWrap: false, + editorMode: 'code', + expr: 'go_gc_duration_seconds{job=$query1)', + fullMetaSearch: false, + includeNullMetadata: true, + instant: false, + legendFormat: '__auto', + range: true, + refId: 'A', + useBackend: false, + }, + ], + title: 'Panel Title', + type: 'timeseries', + }, + ], + schemaVersion: 40, +}; + describe('createDependencyNodes', () => { it('should create node for each variable', () => { const variables = [ @@ -38,3 +93,73 @@ describe('createDependencyEdges', () => { expect(graphEdges).toContainEqual({ from: 'C', to: 'B' }); }); }); + +describe('createUsagesNetwork', () => { + it('should create usage network for variables', () => { + const variables = [ + new TestVariable({ + type: 'query', + name: 'query0', + loading: false, + error: null, + }), + new TestVariable({ + type: 'query', + name: 'query1', + loading: false, + error: null, + }), + ]; + + const usagesNetwork = createUsagesNetwork(variables, dashboardMock); + expect(usagesNetwork).toHaveLength(2); + expect(usagesNetwork[0].variable.state.name).toBe('query0'); + expect(usagesNetwork[1].variable.state.name).toBe('query1'); + }); + + it('should not create usage network for variables that are not part of the dashboard', () => { + const variables = [ + new TestVariable({ + type: 'query', + name: 'query3', + loading: false, + error: null, + }), + ]; + + const usagesNetwork = createUsagesNetwork(variables, dashboardMock); + expect(usagesNetwork).toHaveLength(0); + }); +}); + +describe('transformUsagesToNetwork', () => { + it('should transform usages to network', () => { + const variables = [ + new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] }), + new TestVariable({ name: 'B', query: 'B.*', value: '', text: '', options: [] }), + ]; + const usages = [ + { variable: variables[0], tree: { key: 'value' } }, + { variable: variables[1], tree: { key: 'value' } }, + ]; + + const network = transformUsagesToNetwork(usages); + expect(network).toHaveLength(2); + expect(network[0].nodes).toContainEqual({ id: 'dashboard', label: 'dashboard' }); + expect(network[0].edges).toHaveLength(2); + }); +}); + +describe('getVariableName', () => { + it('should return undefined if no match is found', () => { + expect(getVariableName('no variable here')).toBeUndefined(); + }); + + it('should return undefined if variable matches inherited object prop names', () => { + expect(getVariableName('${toString}')).toBeUndefined(); + }); + + it('should return the variable name if it exists and does not match inherited object prop names', () => { + expect(getVariableName('${myVariable}')).toBe('myVariable'); + }); +}); diff --git a/public/app/features/dashboard-scene/variables/utils.ts b/public/app/features/dashboard-scene/variables/utils.ts index d5bc99e401c..dd9595ea547 100644 --- a/public/app/features/dashboard-scene/variables/utils.ts +++ b/public/app/features/dashboard-scene/variables/utils.ts @@ -1,5 +1,9 @@ import { SceneVariable, SceneVariableState } from '@grafana/scenes'; -import { GraphEdge, GraphNode } from 'app/features/variables/inspect/utils'; +import { Dashboard } from '@grafana/schema/dist/esm/index.gen'; +import { safeStringifyValue } from 'app/core/utils/explore'; +import { GraphEdge, GraphNode, getPropsWithVariable } from 'app/features/variables/inspect/utils'; + +export const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; export function createDependencyNodes(variables: Array>): GraphNode[] { return variables.map((variable) => ({ id: variable.state.name, label: `${variable.state.name}` })); @@ -26,3 +30,222 @@ export const createDependencyEdges = (variables: Array; + tree: unknown; +} + +export interface UsagesToNetwork { + /** string when unknown/missing variable otherwise SceneVariable */ + variable: string | SceneVariable; + nodes: GraphNode[]; + edges: GraphEdge[]; + showGraph: boolean; +} + +export function createUsagesNetwork(variables: Array>, dashboard: Dashboard) { + if (!dashboard) { + return []; + } + + let usages: VariableUsageTree[] = []; + + for (const variable of variables) { + const variableId = variable.state.name; + const props = getPropsWithVariable(variableId, { key: 'model', value: dashboard }, {}); + + if (Object.keys(props).length) { + usages.push({ variable, tree: props }); + } + } + + return usages; +} + +export function transformUsagesToNetwork( + usages: Array +): UsagesToNetwork[] { + const results: UsagesToNetwork[] = []; + + for (const usage of usages) { + const { variable, tree } = usage; + const result: UsagesToNetwork = { + variable, + nodes: [{ id: 'dashboard', label: 'dashboard' }], + edges: [], + showGraph: false, + }; + results.push(traverseTree(result, { id: 'dashboard', value: tree })); + } + + return results; +} + +export const traverseTree = (usage: UsagesToNetwork, parent: { id: string; value: unknown }): UsagesToNetwork => { + const { id, value } = parent; + const { nodes, edges } = usage; + + if (value && typeof value === 'string') { + const leafId = `${parent.id}-${value}`; + nodes.push({ id: leafId, label: value }); + edges.push({ from: leafId, to: id }); + + return usage; + } + + if (isRecord(value)) { + const keys = Object.keys(value); + for (const key of keys) { + const leafId = `${parent.id}-${key}`; + nodes.push({ id: leafId, label: key }); + edges.push({ from: leafId, to: id }); + usage = traverseTree(usage, { id: leafId, value: value[key] }); + } + + return usage; + } + + return usage; +}; + +export const getVariableUsages = (variableId: string, usages: VariableUsageTree[]): number => { + const usage = usages.find((usage) => usage.variable.state.name === variableId); + if (!usage) { + return 0; + } + + if (isRecord(usage.tree)) { + return countLeaves(usage.tree); + } + + return 0; +}; + +const countLeaves = (object: object): number => { + const total = Object.values(object).reduce((count, value) => { + if (typeof value === 'object') { + return count + countLeaves(value); + } + + return count + 1; + }, 0); + + return total; +}; + +export async function getUnknownsNetwork( + variables: Array>, + dashboard: Dashboard | null +): Promise { + return new Promise((resolve, reject) => { + // can be an expensive call so we avoid blocking the main thread + setTimeout(() => { + try { + const unknowns = createUnknownsNetwork(variables, dashboard); + resolve(transformUsagesToNetwork(unknowns)); + } catch (e) { + reject(e); + } + }, 200); + }); +} + +type UnknownVariableUsageTree = { + variable: string; + tree: unknown; +}; + +function createUnknownsNetwork( + variables: Array>, + dashboard: Dashboard | null +): UnknownVariableUsageTree[] { + if (!dashboard) { + return []; + } + + let unknown: UnknownVariableUsageTree[] = []; + const unknownVariables = getUnknownVariableStrings(variables, dashboard); + for (const unknownVariable of unknownVariables) { + const props = getPropsWithVariable(unknownVariable, { key: 'model', value: dashboard }, {}); + if (Object.keys(props).length) { + unknown.push({ variable: unknownVariable, tree: props }); + } + } + + return unknown; +} + +export const getUnknownVariableStrings = (variables: Array>, model: Dashboard) => { + variableRegex.lastIndex = 0; + const unknownVariableNames: string[] = []; + const modelAsString = safeStringifyValue(model, 2); + const matches = modelAsString.match(variableRegex); + + if (!matches) { + return unknownVariableNames; + } + + for (const match of matches) { + if (!match) { + continue; + } + + if (match.indexOf('$__') !== -1) { + // ignore builtin variables + continue; + } + + if (match.indexOf('${__') !== -1) { + // ignore builtin variables + continue; + } + + if (match.indexOf('$hashKey') !== -1) { + // ignore Angular props + continue; + } + + const variableName = getVariableName(match); + + if (variables.some((variable) => variable.state.name === variableName)) { + // ignore defined variables + continue; + } + + if (unknownVariableNames.find((name) => name === variableName)) { + continue; + } + + if (variableName) { + unknownVariableNames.push(variableName); + } + } + + return unknownVariableNames; +}; + +export function getVariableName(expression: string) { + const match = variableRegexExec(expression); + if (!match) { + return undefined; + } + const variableName = match.slice(1).find((match) => match !== undefined); + + // ignore variables that match inherited object prop names + if (variableName! in {}) { + return undefined; + } + + return variableName; +} + +// Helper function since lastIndex is not reset +export const variableRegexExec = (variableString: string) => { + variableRegex.lastIndex = 0; + return variableRegex.exec(variableString); +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/public/app/features/datasources/components/picker/DataSourcePicker.test.tsx b/public/app/features/datasources/components/picker/DataSourcePicker.test.tsx index d6accb33324..727cd1a2e59 100644 --- a/public/app/features/datasources/components/picker/DataSourcePicker.test.tsx +++ b/public/app/features/datasources/components/picker/DataSourcePicker.test.tsx @@ -1,6 +1,5 @@ import { findByText, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import userEvent, { UserEvent } from '@testing-library/user-event'; import { DataSourceInstanceSettings, diff --git a/public/app/features/manage-dashboards/utils/validation.ts b/public/app/features/manage-dashboards/utils/validation.ts index df4b3a1bef8..b6d78b9b952 100644 --- a/public/app/features/manage-dashboards/utils/validation.ts +++ b/public/app/features/manage-dashboards/utils/validation.ts @@ -1,6 +1,6 @@ import { t } from 'i18next'; -import { getBackendSrv } from '@grafana/runtime'; +import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { validationSrv } from '../services/ValidationSrv'; @@ -47,8 +47,8 @@ export const validateTitle = (newTitle: string, folderUid: string) => { }; export const validateUid = (value: string) => { - return getBackendSrv() - .get(`/api/dashboards/uid/${value}`) + return getDashboardAPI() + .getDashboardDTO(value) .then((existingDashboard) => { return `Dashboard named '${existingDashboard?.dashboard.title}' in folder '${existingDashboard?.meta.folderTitle}' has the same UID`; }) diff --git a/public/app/features/migrate-to-cloud/api/index.ts b/public/app/features/migrate-to-cloud/api/index.ts index 85af33ebdfd..c39331c8edb 100644 --- a/public/app/features/migrate-to-cloud/api/index.ts +++ b/public/app/features/migrate-to-cloud/api/index.ts @@ -1,5 +1,5 @@ export * from './endpoints.gen'; -import { BaseQueryFn, EndpointDefinition } from '@reduxjs/toolkit/dist/query'; +import { BaseQueryFn, EndpointDefinition } from '@reduxjs/toolkit/query'; import { generatedAPI } from './endpoints.gen'; diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index 45f95e16263..0479721f708 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -94,6 +94,7 @@ async function getPluginVersions(id: string, isPublished: boolean): Promise @@ -22,7 +26,7 @@ export function PluginDetailsRightPanel(props: Props): React.ReactElement | null Installed version: -
{plugin.installedVersion}
+
{plugin.installedVersion}
)} {info.map((infoItem, index) => { @@ -32,7 +36,9 @@ export function PluginDetailsRightPanel(props: Props): React.ReactElement | null Latest version: -
{getLatestCompatibleVersion(plugin.details?.versions)?.version}
+
+ {getLatestCompatibleVersion(plugin.details?.versions)?.version} +
); } @@ -98,3 +104,11 @@ export function PluginDetailsRightPanel(props: Props): React.ReactElement | null ); } + +export const getStyles = (theme: GrafanaTheme2) => { + return { + pluginVersionDetails: css({ + wordBreak: 'break-word', + }), + }; +}; diff --git a/public/app/features/plugins/admin/components/VersionInstallButton.tsx b/public/app/features/plugins/admin/components/VersionInstallButton.tsx index a26973b704c..58774fc30b8 100644 --- a/public/app/features/plugins/admin/components/VersionInstallButton.tsx +++ b/public/app/features/plugins/admin/components/VersionInstallButton.tsx @@ -20,6 +20,7 @@ interface Props { latestCompatibleVersion?: string; installedVersion?: string; disabled: boolean; + tooltip?: string; onConfirmInstallation: () => void; } @@ -29,6 +30,7 @@ export const VersionInstallButton = ({ latestCompatibleVersion, installedVersion, disabled, + tooltip, onConfirmInstallation, }: Props) => { const install = useInstall(); @@ -119,6 +121,8 @@ export const VersionInstallButton = ({ onClick={onInstallClick} className={styles.button} hidden={hidden} + tooltip={tooltip} + tooltipPlacement="bottom-start" > {label} {isInstalling ? : getIcon(label)} diff --git a/public/app/features/plugins/admin/components/VersionList.tsx b/public/app/features/plugins/admin/components/VersionList.tsx index 56bc3f4f6d6..f910ea19ae0 100644 --- a/public/app/features/plugins/admin/components/VersionList.tsx +++ b/public/app/features/plugins/admin/components/VersionList.tsx @@ -1,6 +1,5 @@ import { css } from '@emotion/css'; import { useEffect, useState } from 'react'; -import { satisfies } from 'semver'; import { dateTimeFormatTimeAgo, GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; @@ -23,8 +22,6 @@ export const VersionList = ({ pluginId, versions = [], installedVersion }: Props const [isInstalling, setIsInstalling] = useState(false); - const grafanaVersion = config.buildInfo.version; - useEffect(() => { setIsInstalling(false); }, [installedVersion]); @@ -49,10 +46,17 @@ export const VersionList = ({ pluginId, versions = [], installedVersion }: Props {versions.map((version) => { + let tooltip: string | undefined = undefined; const isInstalledVersion = installedVersion === version.version; - const versionIsIncompatible = version.grafanaDependency - ? !satisfies(grafanaVersion, version.grafanaDependency, { includePrerelease: true }) - : false; + const canInstall = version.angularDetected ? config.angularSupportEnabled : true; + + if (!canInstall) { + tooltip = 'This plugin version is AngularJS type which is not supported'; + } + + if (!version.isCompatible) { + tooltip = 'This plugin version is not compatible with the current Grafana version'; + } return ( @@ -73,7 +77,8 @@ export const VersionList = ({ pluginId, versions = [], installedVersion }: Props latestCompatibleVersion={latestCompatibleVersion?.version} installedVersion={installedVersion} onConfirmInstallation={onInstallClick} - disabled={isInstalledVersion || isInstalling || versionIsIncompatible} + disabled={isInstalledVersion || isInstalling || !canInstall || !version.isCompatible || !canInstall} + tooltip={tooltip} /> @@ -110,6 +115,9 @@ const getStyles = (theme: GrafanaTheme2) => ({ th: { fontSize: theme.typography.h5.fontSize, }, + td: { + wordBreak: 'break-word', + }, 'tbody tr:nth-child(odd)': { background: theme.colors.emphasize(theme.colors.background.primary, 0.02), }, diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 2ed5a48d495..d9fee5ffc46 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -216,6 +216,7 @@ export interface Version { createdAt: string; isCompatible: boolean; grafanaDependency: string | null; + angularDetected?: boolean; } export interface PluginDetails { @@ -319,6 +320,7 @@ export type PluginVersion = { links: Array<{ rel: string; href: string }>; isCompatible: boolean; grafanaDependency: string | null; + angularDetected?: boolean; }; export type InstancePlugin = { diff --git a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.test.tsx b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.test.tsx index c53a21e423c..9ae9327dae4 100644 --- a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filters.test.tsx @@ -1,6 +1,5 @@ import { act, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import userEvent, { UserEvent } from '@testing-library/user-event'; import * as React from 'react'; import { of } from 'rxjs'; diff --git a/public/app/plugins/datasource/azuremonitor/package.json b/public/app/plugins/datasource/azuremonitor/package.json index 0f5ddb846d7..1bebca032ee 100644 --- a/public/app/plugins/datasource/azuremonitor/package.json +++ b/public/app/plugins/datasource/azuremonitor/package.json @@ -28,6 +28,7 @@ "@grafana/e2e-selectors": "11.4.0-pre", "@grafana/plugin-configs": "11.4.0-pre", "@testing-library/dom": "10.4.0", + "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.0.1", "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.14", diff --git a/public/app/plugins/datasource/azuremonitor/tsconfig.json b/public/app/plugins/datasource/azuremonitor/tsconfig.json index baaaea04185..40352099203 100644 --- a/public/app/plugins/datasource/azuremonitor/tsconfig.json +++ b/public/app/plugins/datasource/azuremonitor/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "jsx": "react-jsx" + "jsx": "react-jsx", + "types": ["node", "jest", "@testing-library/jest-dom"] }, "extends": "@grafana/plugin-configs/tsconfig.json", "include": ["."] diff --git a/public/app/plugins/datasource/cloud-monitoring/package.json b/public/app/plugins/datasource/cloud-monitoring/package.json index 7b505cd0eda..b8e82012218 100644 --- a/public/app/plugins/datasource/cloud-monitoring/package.json +++ b/public/app/plugins/datasource/cloud-monitoring/package.json @@ -29,6 +29,7 @@ "@grafana/e2e-selectors": "11.4.0-pre", "@grafana/plugin-configs": "11.4.0-pre", "@testing-library/dom": "10.4.0", + "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.0.1", "@testing-library/user-event": "14.5.2", "@types/debounce-promise": "3.1.9", diff --git a/public/app/plugins/datasource/cloud-monitoring/tsconfig.json b/public/app/plugins/datasource/cloud-monitoring/tsconfig.json index baaaea04185..40352099203 100644 --- a/public/app/plugins/datasource/cloud-monitoring/tsconfig.json +++ b/public/app/plugins/datasource/cloud-monitoring/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "jsx": "react-jsx" + "jsx": "react-jsx", + "types": ["node", "jest", "@testing-library/jest-dom"] }, "extends": "@grafana/plugin-configs/tsconfig.json", "include": ["."] diff --git a/public/app/plugins/datasource/loki/LanguageProvider.test.ts b/public/app/plugins/datasource/loki/LanguageProvider.test.ts index c397934b356..c801a15c7a5 100644 --- a/public/app/plugins/datasource/loki/LanguageProvider.test.ts +++ b/public/app/plugins/datasource/loki/LanguageProvider.test.ts @@ -441,6 +441,14 @@ describe('Language completion provider', () => { start: 1560153109000, }); }); + + it('should filter internal labels', async () => { + const datasourceWithLabels = setup({ foo: [], bar: [], __name__: [], __stream_shard__: [] }); + + const instance = new LanguageProvider(datasourceWithLabels); + const labels = await instance.fetchLabels(); + expect(labels).toEqual(['bar', 'foo']); + }); }); }); diff --git a/public/app/plugins/datasource/loki/LanguageProvider.ts b/public/app/plugins/datasource/loki/LanguageProvider.ts index e56acf6f3d7..5d6e5684f2b 100644 --- a/public/app/plugins/datasource/loki/LanguageProvider.ts +++ b/public/app/plugins/datasource/loki/LanguageProvider.ts @@ -176,7 +176,7 @@ export default class LokiLanguageProvider extends LanguageProvider { const labels = res .slice() .sort() - .filter((label) => label !== '__name__'); + .filter((label: string) => label.startsWith('__') === false); this.labelKeys = labels; return this.labelKeys; } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index bf8c92baf02..f430b69d65d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3336,6 +3336,12 @@ "info-box-content": "Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor names in your metric queries you can use variables in their place. Variables are shown as list boxes at the top of the dashboard. These drop-down lists make it easy to change the data being displayed in your dashboard.", "info-box-content-2": "Check out the <2>Templates and variables documentation for more information.", "title": "There are no variables added yet" + }, + "unknown-table": { + "loading": "Loading...", + "no-unknowns": "No renamed or missing variables found.", + "renamed-or-missing-variables": "Renamed or missing variables", + "variable": "Variable" } } } diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index b6d68de3c92..fe6defcf56c 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -3336,6 +3336,12 @@ "info-box-content": "Väřįäþľęş ęʼnäþľę mőřę įʼnŧęřäčŧįvę äʼnđ đyʼnämįč đäşĥþőäřđş. Ĩʼnşŧęäđ őƒ ĥäřđ-čőđįʼnģ ŧĥįʼnģş ľįĸę şęřvęř őř şęʼnşőř ʼnämęş įʼn yőūř męŧřįč qūęřįęş yőū čäʼn ūşę väřįäþľęş įʼn ŧĥęįř pľäčę. Väřįäþľęş äřę şĥőŵʼn äş ľįşŧ þőχęş äŧ ŧĥę ŧőp őƒ ŧĥę đäşĥþőäřđ. Ŧĥęşę đřőp-đőŵʼn ľįşŧş mäĸę įŧ ęäşy ŧő čĥäʼnģę ŧĥę đäŧä þęįʼnģ đįşpľäyęđ įʼn yőūř đäşĥþőäřđ.", "info-box-content-2": "Cĥęčĸ őūŧ ŧĥę <2>Ŧęmpľäŧęş äʼnđ väřįäþľęş đőčūmęʼnŧäŧįőʼn ƒőř mőřę įʼnƒőřmäŧįőʼn.", "title": "Ŧĥęřę äřę ʼnő väřįäþľęş äđđęđ yęŧ" + }, + "unknown-table": { + "loading": "Ŀőäđįʼnģ...", + "no-unknowns": "Ńő řęʼnämęđ őř mįşşįʼnģ väřįäþľęş ƒőūʼnđ.", + "renamed-or-missing-variables": "Ŗęʼnämęđ őř mįşşįʼnģ väřįäþľęş", + "variable": "Väřįäþľę" } } } diff --git a/public/openapi3.json b/public/openapi3.json index b720c4e8b07..6f003b70069 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -1405,6 +1405,16 @@ }, "description": "(empty)" }, + "getUsers": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUsersBody" + } + } + }, + "description": "(empty)" + }, "goneError": { "content": { "application/json": { @@ -5114,6 +5124,21 @@ }, "type": "object" }, + "EmailSchema": { + "description": "Implementation of SCIM specification email schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.2", + "properties": { + "primary": { + "type": "boolean" + }, + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "type": "object" + }, "EmbeddedContactPoint": { "description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.", "properties": { @@ -5828,6 +5853,35 @@ }, "type": "object" }, + "GetUsersBody": { + "properties": { + "Resources": { + "items": { + "$ref": "#/components/schemas/UserSchema" + }, + "type": "array" + }, + "itemsPerPage": { + "format": "int64", + "type": "integer" + }, + "schemas": { + "items": { + "type": "string" + }, + "type": "array" + }, + "startIndex": { + "format": "int64", + "type": "integer" + }, + "totalResults": { + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, "GettableAlertmanagers": { "properties": { "data": { @@ -7124,6 +7178,29 @@ }, "type": "array" }, + "MetaSchema": { + "description": "Implementation of SCIM specification meta schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-3.1", + "properties": { + "created": { + "format": "date-time", + "type": "string" + }, + "lastModified": { + "format": "date-time", + "type": "string" + }, + "location": { + "type": "string" + }, + "resourceType": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "type": "object" + }, "Metadata": { "additionalProperties": { "type": "boolean" @@ -7350,6 +7427,24 @@ }, "type": "object" }, + "NameSchema": { + "description": "Implementation of SCIM specification name schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.1", + "properties": { + "familyName": { + "type": "string" + }, + "formatted": { + "type": "string" + }, + "givenName": { + "type": "string" + }, + "middleName": { + "type": "string" + } + }, + "type": "object" + }, "NamespaceConfigResponse": { "additionalProperties": { "items": { @@ -12211,6 +12306,45 @@ }, "type": "object" }, + "UserSchema": { + "description": "Implementation of SCIM specification user schema\nhttps://datatracker.ietf.org/doc/html/rfc7643#section-4.1.1", + "properties": { + "active": { + "type": "boolean" + }, + "displayName": { + "type": "string" + }, + "emails": { + "items": { + "$ref": "#/components/schemas/EmailSchema" + }, + "type": "array" + }, + "externalId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "meta": { + "$ref": "#/components/schemas/MetaSchema" + }, + "name": { + "$ref": "#/components/schemas/NameSchema" + }, + "schemas": { + "items": { + "type": "string" + }, + "type": "array" + }, + "userName": { + "type": "string" + } + }, + "type": "object" + }, "UserSearchHitDTO": { "properties": { "authLabels": { @@ -22484,12 +22618,63 @@ } }, "/scim/users/": { + "delete": { + "operationId": "deleteUser", + "responses": { + "200": { + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Deletes user.", + "tags": [ + "user" + ] + }, + "get": { + "operationId": "getUsers", + "responses": { + "200": { + "$ref": "#/components/responses/getUsers" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Fetches all users in UserSchema format.", + "tags": [ + "users" + ] + }, "post": { "operationId": "createUser", "responses": { "200": { "$ref": "#/components/responses/okResponse" }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, "401": { "$ref": "#/components/responses/unauthorisedError" }, @@ -22509,6 +22694,32 @@ ] } }, + "/scim/users/:id": { + "get": { + "operationId": "getUser", + "responses": { + "200": { + "$ref": "#/components/responses/okResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Gets user by id.", + "tags": [ + "user" + ] + } + }, "/search": { "get": { "operationId": "search", diff --git a/scripts/cli/tsconfig.json b/scripts/cli/tsconfig.json index be60908b35d..9ad59a649d1 100644 --- a/scripts/cli/tsconfig.json +++ b/scripts/cli/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "moduleResolution": "node", "module": "commonjs" }, "extends": "../../tsconfig.json", diff --git a/tsconfig.json b/tsconfig.json index c24104df189..42db6464f66 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "useUnknownInCatchVariables": true, "incremental": true, "isolatedModules": true, + "moduleResolution": "bundler", "tsBuildInfoFile": "./tsconfig.tsbuildinfo", "paths": { "@grafana/schema/dist/esm/*": ["../packages/grafana-schema/src/*"] diff --git a/yarn.lock b/yarn.lock index 5dfb36f7583..3d949ee525c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2785,6 +2785,7 @@ __metadata: "@grafana/ui": "npm:11.4.0-pre" "@kusto/monaco-kusto": "npm:^10.0.0" "@testing-library/dom": "npm:10.4.0" + "@testing-library/jest-dom": "npm:6.6.3" "@testing-library/react": "npm:16.0.1" "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.14" @@ -3075,6 +3076,7 @@ __metadata: "@grafana/schema": "npm:11.4.0-pre" "@grafana/ui": "npm:11.4.0-pre" "@testing-library/dom": "npm:10.4.0" + "@testing-library/jest-dom": "npm:6.6.3" "@testing-library/react": "npm:16.0.1" "@testing-library/user-event": "npm:14.5.2" "@types/debounce-promise": "npm:3.1.9" @@ -3990,7 +3992,7 @@ __metadata: rc-time-picker: "npm:^3.7.3" rc-tooltip: "npm:6.2.1" react: "npm:18.2.0" - react-calendar: "npm:5.1.0" + react-calendar: "npm:^5.1.0" react-colorful: "npm:5.6.1" react-custom-scrollbars-2: "npm:4.5.0" react-dom: "npm:18.2.0" @@ -6463,19 +6465,19 @@ __metadata: languageName: node linkType: hard -"@react-types/menu@npm:3.9.12": - version: 3.9.12 - resolution: "@react-types/menu@npm:3.9.12" +"@react-types/menu@npm:3.9.13": + version: 3.9.13 + resolution: "@react-types/menu@npm:3.9.13" dependencies: - "@react-types/overlays": "npm:^3.8.10" - "@react-types/shared": "npm:^3.25.0" + "@react-types/overlays": "npm:^3.8.11" + "@react-types/shared": "npm:^3.26.0" peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10/a6276115c5da08c63b9641b985c366435e69847af680ace5fef32ce2ca6eab9669db5cc8f5a20e01377a54f17392a95864ee40831b13b2258ed6d43bcc1398f5 + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10/ce1d5310e16300e06ad7c4a940525279b54d097242e3c88f3e6272e130d5036f18fda132c78836f8eac1da0a19a5f08d1c560491f8d8a30ff1dab5ea95b89852 languageName: node linkType: hard -"@react-types/overlays@npm:3.8.10, @react-types/overlays@npm:^3.8.10": +"@react-types/overlays@npm:3.8.10": version: 3.8.10 resolution: "@react-types/overlays@npm:3.8.10" dependencies: @@ -6486,6 +6488,17 @@ __metadata: languageName: node linkType: hard +"@react-types/overlays@npm:^3.8.10, @react-types/overlays@npm:^3.8.11": + version: 3.8.11 + resolution: "@react-types/overlays@npm:3.8.11" + dependencies: + "@react-types/shared": "npm:^3.26.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10/3906806bda8d953d46409214be98b4e7a58d1b677d29f358f6a28e9a6238d0672b83382527607a69ccc94174c0b156b3f96140b950a4fab5c44775382929d3f9 + languageName: node + linkType: hard + "@react-types/shared@npm:3.25.0": version: 3.25.0 resolution: "@react-types/shared@npm:3.25.0" @@ -17223,7 +17236,7 @@ __metadata: "@react-aria/utils": "npm:3.25.3" "@react-awesome-query-builder/ui": "npm:6.6.4" "@react-types/button": "npm:3.10.1" - "@react-types/menu": "npm:3.9.12" + "@react-types/menu": "npm:3.9.13" "@react-types/overlays": "npm:3.8.10" "@react-types/shared": "npm:3.25.0" "@reduxjs/toolkit": "npm:2.3.0" @@ -24738,7 +24751,7 @@ __metadata: languageName: node linkType: hard -"react-calendar@npm:5.1.0": +"react-calendar@npm:^5.1.0": version: 5.1.0 resolution: "react-calendar@npm:5.1.0" dependencies: